UIScrollView的滚动交互如何传递给另一个UIScrollView?

6
我可以帮您进行翻译。以下是需要翻译的内容:

我有一个接口,其结构如下,其中重要元素的名称用括号括起来:

- UIViewController
      - UIScrollView (ScrollViewA)
           - UIViewController (ProfileOverviewViewController)
           - UIViewController (ProfileDetailViewController)
               - UICollectionView (ScrollViewB)

基本上,一个垂直滚动视图(ScrollViewB)位于另一个垂直滚动视图(ScrollViewA)内部。ProfileOverviewViewController和ProfileDetailViewController都与设备屏幕大小相同,因此只有在ScrollViewA滚动到底部后,ScrollViewB才可见。
ScrollViewA启用了分页功能,因此会切换到占据整个屏幕视图的ProfileOverviewViewController或ProfileDetailViewController。
希望这张图能让布局更加清晰: Layout 我的问题是:
  • 如果用户滚动到ScrollViewA的底部,使ProfileDetailViewController和ScrollViewB可见。
  • 用户在ScrollViewB上向下滚动一点,然后释放手指。
  • 然后用户在ScrollViewB上向上滚动。
  • 当用户在ScrollViewB中的内容顶部仍然按住手指时,ScrollViewB应停止滚动,并且ScrollViewA应该开始向上滚动,直至到达ProfileOverviewViewController,所有这些都在同一个手势中进行。
而不是当前ScrollViewB将简单地延伸到负y内容偏移,因为bounces属性为true。
如何在ScrollViewB滚动到顶部时将滚动传递给ScrollViewA?
谢谢。

你的层次结构非常混乱。如果您告诉我们UI看起来像什么或者您最终的输出是什么,那么我们可能会给出更好的建议来维护视图层次结构。 - dahiya_boy
它应该自动工作,查看播放器屏幕-> https://itunes.apple.com/fr/app/luxe-radio/id1073120504?mt=8 - SPatel
2个回答

7

这是一个非常好的问题,我不得不深入挖掘才找到一个合适的解决方案。以下是有注释的代码。思路是向scrollViewB添加自定义pan手势,并将ProfileDetailViewController设置为其手势代理。 当滑动将scrollViewB滑动到顶部时,ProfileOverviewViewController会收到警告并开始滚动scrollViewA。当用户松开手指时,ProfileOverviewViewController决定是否滚动到内容的底部或顶部。

希望能对您有所帮助 :)

ProfileDetailViewController :

//
//  ProfileDetailViewController.swift
//  Sandbox
//
//  Created by Eric Blachère on 23/12/2018.
//  Copyright © 2018 Eric Blachère. All rights reserved.
//

import UIKit

protocol OverflowDelegate: class {
    func onOverflowEnded()
    func onOverflow(delta: CGFloat)
}

/// State of overflow of scrollView
///
/// - on: The scrollview is overflowing : ScrollViewA should take the lead. We store the last trnaslation of the gesture
/// - off: No overflow detected
enum OverflowState {
    case on(lastRecordedGestureTranslation: CGFloat)
    case off

    var isOn: Bool {
        switch self {
        case .on:
            return true
        case .off:
            return false
        }
    }
}

class ProfileDetailViewController: UIViewController, UIScrollViewDelegate, UIGestureRecognizerDelegate {

    @IBOutlet weak var scrollviewB: UIScrollView!

    weak var delegate: OverflowDelegate?

    /// a pan gesture added on scrollView B
    var customPanGesture: UIPanGestureRecognizer!
    /// The state of the overflow
    var overflowState = OverflowState.off

    override func viewDidLoad() {
        super.viewDidLoad()

        // create a custom pan gesture recognizer added on scrollview B. This way we can be delegate of this gesture & follow the finger
        customPanGesture = UIPanGestureRecognizer(target: self, action: #selector(self.panRecognized(gesture:)))
        scrollviewB.addGestureRecognizer(customPanGesture)
        customPanGesture.delegate = self

        scrollviewB.delegate = self
    }


    @objc func panRecognized(gesture: UIPanGestureRecognizer) {
        switch overflowState {
        case .on(let lastRecordedGestureTranslation):
            // the user just released his finger
            if gesture.state == .ended {
                print("didEnd !!")
                delegate?.onOverflowEnded() // warn delegate
                overflowState = .off // end of overflow
                scrollviewB.panGestureRecognizer.isEnabled = true // enable scroll again
                return
            }

            // compute the translation delta & send it to delegate
            let fullTranslationY = gesture.translation(in: view).y
            let delta = fullTranslationY - lastRecordedGestureTranslation
            overflowState = .on(lastRecordedGestureTranslation: fullTranslationY)
            delegate?.onOverflow(delta: delta)
        case .off:
            return
        }
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        if scrollView.contentOffset.y <= 0 { // scrollview B is at the top
            // if the overflow is starting : initilize
            if !overflowState.isOn {
                let translation = self.customPanGesture.translation(in: self.view)
                self.overflowState = .on(lastRecordedGestureTranslation: translation.y)

                // disable scroll as we don't scroll in this scrollView from now on
                scrollView.panGestureRecognizer.isEnabled = false
            }
        }
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true // so that both the pan gestures on scrollview will be triggered
    }
}

全局视图控制器:

//
//  GlobalViewController.swift
//  Sandbox
//
//  Created by Eric Blachère on 23/12/2018.
//  Copyright © 2018 Eric Blachère. All rights reserved.
//

import UIKit

class GlobalViewController: UIViewController, OverflowDelegate {

    @IBOutlet weak var scrollViewA: UIScrollView!

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard segue.identifier == "secondSegue", let ctrl = segue.destination as? ProfileDetailViewController else {
            return
        }
        ctrl.delegate = self
    }

    func onOverflowEnded() {
        // scroll to top if at least one third of the overview is showed (you can change this fraction as you please ^^)
        let shouldScrollToTop = (scrollViewA.contentOffset.y <= 2 * scrollViewA.frame.height / 3)
        if shouldScrollToTop {
            scrollViewA.scrollRectToVisible(CGRect(x: 0, y: 0, width: 1, height: 1), animated: true)
        } else {
            scrollViewA.scrollRectToVisible(CGRect(x: 0, y: scrollViewA.contentSize.height - 1, width: 1, height: 1), animated: true)
        }
    }

    func onOverflow(delta: CGFloat) {
        // move the scrollview content
        if scrollViewA.contentOffset.y - delta <= scrollViewA.contentSize.height - scrollViewA.frame.height {
            scrollViewA.contentOffset.y -= delta
            print("difference : \(delta)")
            print("contentOffset : \(scrollViewA.contentOffset.y)")
        }
    }
}

编辑:ProfileOverviewViewController和ProfileDetailViewController通过容器视图在Storyboard中设置为GlobalViewController,但如果在代码中设置也应该可以工作;)


1
谢谢,这个很好用!我做了一些额外的更改,使用户可以向上滚动超过ScrollViewB的末尾以触发ScrollViewA中的滚动,然后向下滚动并继续在ScrollViewB中滚动,所有这些都在同一个手势中完成。抱歉,我误解了另一个用户的答案,因此无法为您提供赏金,但这是正确的答案。 - jacobsieradzki

2
以下是一个示例,其中包含 OP 列出的结构的稍微修改版本。在此处更新了结构:
- UIPageViewController
      - UIViewController (ProfileOverviewViewController)
      - UIViewController (ProfileDetailViewController)
            - UICollectionView (ScrollViewB)

我们已经将原来包含一个滚动视图的父视图控制器替换为UIPageViewController。此更改的目的是获得分页功能以及UIPageViewControllerDataSourceUIPageViewControllerDelegate函数。请注意保留html标签。
//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

// MARK: - ScrollViewAController

final class ScrollViewAController : UIPageViewController {

    private var _viewControllers: [UIViewController] = []

    convenience init(viewControllers: [UIViewController]) {
        self.init(transitionStyle: .scroll, navigationOrientation: .vertical, options: nil)
        self._viewControllers = viewControllers
        dataSource = self
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setViewControllers([_viewControllers.first!], direction: .forward, animated: true, completion: nil)
    }
}

// MARK: UIPageViewControllerDataSource

extension ScrollViewAController: UIPageViewControllerDataSource {

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {

        let viewControllers = _viewControllers
        guard let index = _viewControllers.index(of: viewController) else {
            return nil // view controller not found
        }

        let previousIndex = index - 1
        guard previousIndex >= 0 else {
            return nil // index is invalid
        }

        guard viewControllers.count > previousIndex else {
            return nil // previous index is invalid
        }

        return viewControllers[previousIndex]
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {

        let viewControllers = _viewControllers
        guard let index = viewControllers.index(of: viewController) else {
            return nil // view controller not found
        }

        let nextIndex = index + 1
        let viewControllersCount = viewControllers.count
        guard viewControllersCount != nextIndex else {
            return nil // next index is out-of-bounds (we're at the last page)
        }

        guard viewControllersCount > nextIndex else {
            return nil // next index is invalid
        }

        return viewControllers[nextIndex]
    }
}

// MARK: - ProfileOverviewViewController

final class ProfileOverviewViewController : UIViewController {

    override func loadView() {
        let view = UIView()
        view.backgroundColor = .green

        let label = UILabel()
        label.text = "ProfileOverviewViewController"
        label.textAlignment = .center
        label.textColor = .white
        view.addSubview(label)

        label.translatesAutoresizingMaskIntoConstraints = false
        label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        label.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        label.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        label.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        self.view = view
    }
}

// MARK: - ProfileDetailViewController

final class ProfileDetailViewController : UIViewController {

    var scrollViewB: UIScrollView! // should be a collection view, but simplified for this sample.

    override func loadView() {
        let view = UIView()
        view.backgroundColor = .orange

        let label = UILabel()
        label.text = "ProfileDetailViewController"
        label.textAlignment = .center
        label.textColor = .white
        view.addSubview(label)

        label.translatesAutoresizingMaskIntoConstraints = false
        label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        label.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        label.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        label.heightAnchor.constraint(equalToConstant: 100).isActive = true

        scrollViewB = UIScrollView()
        scrollViewB.backgroundColor = .blue
        view.addSubview(scrollViewB)

        scrollViewB.translatesAutoresizingMaskIntoConstraints = false
        scrollViewB.topAnchor.constraint(equalTo: label.bottomAnchor).isActive = true
        scrollViewB.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50).isActive = true
        scrollViewB.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50).isActive = true
        scrollViewB.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        let scrollViewBLabel = UILabel()
        scrollViewBLabel.numberOfLines = 0
        scrollViewBLabel.text = "ScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\n"
        scrollViewBLabel.textAlignment = .center
        scrollViewBLabel.textColor = .white
        scrollViewB.addSubview(scrollViewBLabel)

        scrollViewBLabel.translatesAutoresizingMaskIntoConstraints = false
        scrollViewBLabel.topAnchor.constraint(equalTo: scrollViewB.topAnchor).isActive = true
        scrollViewBLabel.leadingAnchor.constraint(equalTo: scrollViewB.leadingAnchor).isActive = true
        scrollViewBLabel.trailingAnchor.constraint(equalTo: scrollViewB.trailingAnchor).isActive = true
        scrollViewBLabel.heightAnchor.constraint(equalToConstant: 1500).isActive = true
        scrollViewBLabel.bottomAnchor.constraint(equalTo: scrollViewB.bottomAnchor).isActive = true

        self.view = view
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        scrollViewB.contentSize = CGSize(width: view.frame.width, height: 2000)
    }
}

// Present the view controller in the Live View window
let viewControllers: [UIViewController] = [
    ProfileOverviewViewController(),
    ProfileDetailViewController(),
]

PlaygroundPage.current.liveView = ScrollViewAController(viewControllers: viewControllers)

真的很棒!为什么它可以与UIPageViewController一起使用,而不能与UIScrollView一起使用? - jacobsieradzki
@jacobsieradzki,使用UIScrollView也可以实现,但是我将其更改为UIPageViewController以获得其他好处,例如分页视图控制器的惰性实例化以及用于处理VC滚动/分页的特定API。 - pxpgraphics
我意识到这个示例在Playgrounds中有效,但实际上并没有回答问题。非常抱歉,将这个回答标记为正确是一个错误(我曾认为UIPageViewController会自动解决我的问题)。 - jacobsieradzki
如果我理解正确,这只解决了Playground实时预览中的问题?(我只在playground中测试过,似乎可以工作) - Bioche
问题在您提供的Playground中有效,我认为解决问题的方法是使用UIPageViewController而不是UIScrollView。但是,既然您说这不是问题的原因,那么这并没有解决我的问题,您基本上重新创建了我的视图层次结构。当我稍后在自己的项目中实现您的重建时,并没有解决所询问的问题。 - jacobsieradzki
@jacobsieradzki 好的,我现在没有电脑(我正在度假),但我会用另一个可行的版本更新这个答案。你能解释一下这个解决方案不能解决哪个问题吗?从你上面的评论中我不确定哪里出了问题。谢谢! - pxpgraphics

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接