带有 UIPageViewController 的弹性 Header

Stretchy Header with UIPageViewController

我的问题似乎很明显且重复,但我无法解决它。

我正在尝试实现著名的弹性 header 效果(滚动时图像的顶部粘在 UIScrollView 的顶部),但使用 UIPageViewController 而不是简单的图像.

我的结构是:

UINavigationBar
   |-- UIScrollView
         |-- UIView (totally optional container)
              |-- UIPageViewController (as UIView, embedded with addChild()) <-- TO STICK
              |-- UIHostingViewController (SwiftUI view with labels, also embedded)
              |-- UITableView (not embedded but could be)

我的 UIPageViewController 包含用于制作轮播的图像,仅此而已。
我所有的视图都是用 NSLayoutConstraints 布局的(容器中垂直布局的视觉格式)。

我尝试将页面控制器的视图 topAnchor 粘贴到 self.view 之一(有或没有 priority)但没有运气,无论我做什么它都绝对改变没什么。

我终于尝试使用 SnapKit 但它也不起作用(我对此了解不多,但它似乎只是 NSLayoutConstaint 的包装器所以我不要惊讶它也不起作用)。

我关注了 this tutorial, this one and that one 但其中 none 成功了。

(如何)我可以达到我想要的?

编辑 1: 澄清一下,我的旋转木马目前的强制高度为 350。我想在我的整个旋转木马上实现这个确切的效果(用单个 UIImageView 显示):

为了尽可能澄清,我想将这种效果复制到我的整个 UIPageViewController/carousel,以便显示的 page/image 可以在滚动时具有这种效果。

注意: 如上面的结构所述,我有一个(透明的)导航栏,并且我的安全区域插图受到尊重(没有任何内容在状态栏)。我不认为它会改变解决方案(因为解决方案可能是一种将旋转木马的顶部粘贴到 self.view 的方法,无论 self.view 的框架如何)但我希望你知道一切.

编辑 2:
主要 VC 与@DonMag 的回答:

    private let info: UITableView = {
        let v = UITableView(frame: .zero, style: .insetGrouped)
        v.backgroundColor = .systemBackground
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    private lazy var infoHeightConstraint: NSLayoutConstraint = {
        // Needed constraint because else standalone UITableView gets an height of 0 even with usual constraints
        // I update this constraint in viewWillAppear & viewDidAppear when the table gets a proper contentSize
        info.heightAnchor.constraint(equalToConstant: 0.0)
    }()
    
    private let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.contentInsetAdjustmentBehavior = .never
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        ...

        // MARK: Views declaration
        // Container for carousel
        let stretchyView = UIView()
        stretchyView.translatesAutoresizingMaskIntoConstraints = false
        
        // Carousel
        let carouselController = ProfileDetailCarousel(images: [
            UIImage(named: "1")!,
            UIImage(named: "2")!,
            UIImage(named: "3")!,
            UIImage(named: "4")!
        ])
        addChild(carouselController)
        let carousel: UIView = carouselController.view
        carousel.translatesAutoresizingMaskIntoConstraints = false
        stretchyView.addSubview(carousel)
        carouselController.didMove(toParent: self)
        
        // Container for below-carousel views
        let contentView = UIView()
        contentView.translatesAutoresizingMaskIntoConstraints = false
        
        // Texts and bio
        let bioController = UIHostingController(rootView: ProfileDetailBio())
        addChild(bioController)
        let bio: UIView = bioController.view
        bio.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(bio)
        bioController.didMove(toParent: self)
        
        // Info table
        info.delegate = tableDelegate
        info.dataSource = tableDataSource
        tableDelegate.viewController = self
        contentView.addSubview(info)
        
        [stretchyView, contentView].forEach { v in
            scrollView.addSubview(v)
        }
        view.addSubview(scrollView)
        
        // MARK: Constraints
        let stretchyTop = stretchyView.topAnchor.constraint(equalTo: scrollView.frameLayoutGuide.topAnchor)
        stretchyTop.priority = .defaultHigh
        NSLayoutConstraint.activate([
            // Scroll view
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            
            // Stretchy view
            stretchyTop,
            
            stretchyView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor),
            stretchyView.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
            stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 350.0),
            
            // Carousel
            carousel.topAnchor.constraint(equalTo: stretchyView.topAnchor),
            carousel.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
            carousel.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
            carousel.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
            
            // Content view
            contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
            contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
            contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 350.0),
            contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor),
            
            // Bio
            bio.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10.0),
            bio.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            
            // Info table
            info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
            info.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            info.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            infoHeightConstraint
        ])
    }

您的视图层次应该是:

UINavigationBar
   |-- UIScrollView
         |-- UIView ("stretchy" container view)
              |-- UIPageViewController (as UIView, embedded with asChild())
         |-- UIHostingViewController (SwiftUI view with labels, also embedded)

要使弹性视图“粘在顶部”:

我们将弹性视图的顶部约束到滚动视图的 .frameLayoutGuide 顶部,但我们给该约束一个 less-than-required .priority 以便我们可以将其“推”上和离开屏幕.

我们还为可​​伸缩视图提供 greater-than-or-equal-to 350 的高度约束。这将允许它在垂直方向上拉伸 - 但不压缩。

我们将 UIHostingViewController 中的视图称为我们的“contentView”...我们会将其顶部约束到弹性视图的底部。

然后,我们给内容视图 另一个 顶部约束 -- 这次是滚动视图的 .contentLayoutGuide,常数为 350(可伸缩的高度看法)。这加上 Leading/Trailing/Bottom 约束定义了“可滚动区域”。

当我们向下滚动(拉)时,内容视图将“拉下”弹性视图的底部。

当我们向上滚动(推)时,内容视图将“向上推”整个可伸缩视图。

外观如下(太大,无法在此处添加为 gif):https://imgur.com/a/wkThhzN

这是实现它的示例代码。一切都是通过代码完成的,因此不需要 @IBOutlet 或其他连接。另请注意,我使用了三张图片作为页面浏览量 - "ex1"、"ex2"、"ex3":

视图控制器

class StretchyHeaderViewController: UIViewController {
    
    let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.contentInsetAdjustmentBehavior = .never
        return v
    }()
    let stretchyView: UIView = {
        let v = UIView()
        return v
    }()
    let contentView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemYellow
        return v
    }()
    
    let stretchyViewHeight: CGFloat = 350.0
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // set to a greter-than-zero value if you want spacing between the "pages"
        let opts = [UIPageViewController.OptionsKey.interPageSpacing: 0.0]
        // instantiate the Page View controller
        let pgVC = SamplePageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: opts)
        // add it as a child controller
        self.addChild(pgVC)
        // safe unwrap
        guard let pgv = pgVC.view else { return }
        pgv.translatesAutoresizingMaskIntoConstraints = false
        // add the page controller view to stretchyView
        stretchyView.addSubview(pgv)
        pgVC.didMove(toParent: self)
        
        NSLayoutConstraint.activate([
            // constrain page view controller's view on all 4 sides
            pgv.topAnchor.constraint(equalTo: stretchyView.topAnchor),
            pgv.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
            pgv.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
            pgv.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
        ])
        
        [scrollView, stretchyView, contentView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }
        
        // add contentView and stretchyView to the scroll view
        [stretchyView, contentView].forEach { v in
            scrollView.addSubview(v)
        }
        
        // add scroll view to self.view
        view.addSubview(scrollView)
        
        let safeG = view.safeAreaLayoutGuide
        let contentG = scrollView.contentLayoutGuide
        let frameG = scrollView.frameLayoutGuide
        
        // keep stretchyView's Top "pinned" to the Top of the scroll view FRAME
        //  so its Height will "stretch" when scroll view is pulled down
        let stretchyTop = stretchyView.topAnchor.constraint(equalTo: frameG.topAnchor, constant: 0.0)
        // priority needs to be less-than-required so we can "push it up" out of view
        stretchyTop.priority = .defaultHigh
        
        NSLayoutConstraint.activate([
            
            // scroll view Top to view Top
            scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),

            // scroll view Leading/Trailing/Bottom to safe area
            scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
            scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
            scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
            
            // constrain stretchy view Top to scroll view's FRAME
            stretchyTop,
            
            // stretchyView to Leading/Trailing of scroll view FRAME
            stretchyView.leadingAnchor.constraint(equalTo: frameG.leadingAnchor, constant: 0.0),
            stretchyView.trailingAnchor.constraint(equalTo: frameG.trailingAnchor, constant: 0.0),
            
            // stretchyView Height - greater-than-or-equal-to
            //  so it can "stretch" vertically
            stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: stretchyViewHeight),
            
            // content view Leading/Trailing/Bottom to scroll view's CONTENT GUIDE
            contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
            contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
            contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),

            // content view Width to scroll view's FRAME
            contentView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),

            // content view Top to scroll view's CONTENT GUIDE
            //  plus Height of stretchyView
            contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: stretchyViewHeight),

            // content view Top to stretchyView Bottom
            contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor, constant: 0.0),
    
        ])
        
        // add some content to the content view so we have something to scroll
        addSomeContent()
                
    }
    
    func addSomeContent() {
        // vertical stack view with 20 labels
        //  so we have something to scroll
        let stack = UIStackView()
        stack.axis = .vertical
        stack.spacing = 32
        stack.backgroundColor = .gray
        stack.translatesAutoresizingMaskIntoConstraints = false
        for i in 1...20 {
            let v = UILabel()
            v.text = "Label \(i)"
            v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
            v.heightAnchor.constraint(equalToConstant: 48.0).isActive = true
            stack.addArrangedSubview(v)
        }
        contentView.addSubview(stack)
        NSLayoutConstraint.activate([
            stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16.0),
            stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16.0),
            stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16.0),
            stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16.0),
        ])
    }
    
}

每个页面的控制器

class OnePageVC: UIViewController {
    
    var image: UIImage = UIImage() {
        didSet {
            imgView.image = image
        }
    }
    let imgView: UIImageView = {
        let v = UIImageView()
        v.backgroundColor = .systemBlue
        v.contentMode = .scaleAspectFill
        v.clipsToBounds = true
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        view.addSubview(imgView)
        NSLayoutConstraint.activate([
            // constrain image view to all 4 sides
            imgView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
            imgView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
            imgView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
            imgView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
        ])
    }
}

示例页面视图控制器

class SamplePageViewController: UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
    
    var controllers: [UIViewController] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let imgNames: [String] = [
            "ex1", "ex2", "ex3",
        ]
        for i in 0..<imgNames.count {
            let aViewController = OnePageVC()
            if let img = UIImage(named: imgNames[i]) {
                aViewController.image = img
            }
            self.controllers.append(aViewController)
        }

        self.dataSource = self
        self.delegate = self
        
        self.setViewControllers([controllers[0]], direction: .forward, animated: false)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        if let index = controllers.firstIndex(of: viewController), index > 0 {
            return controllers[index - 1]
        }
        return nil
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        if let index = controllers.firstIndex(of: viewController), index < controllers.count - 1 {
            return controllers[index + 1]
        }
        return nil
    }

}

编辑

查看您在问题的编辑中发布的代码...有点困难,因为我不知道您的 ProfileDetailBio 视图是什么,但这里有一些有助于调试此类型的提示开发过程中的情况:

  • 为您的视图提供对比鲜明的背景颜色...让您在 运行 应用
  • 时更容易看到框架
  • 如果一个子视图填满了它的父视图的宽度,让它变窄一点,这样你就可以看到它“后面/下面”的内容
  • 在您用作“容器”的视图上设置 .clipsToBounds = true - 例如 contentView... 如果子视图随后“丢失”,您知道它已经超出了容器

所以,对于您的代码...

// so we can see the contentView frame
contentView.backgroundColor = .systemYellow

// leave some space on the right-side of bio view, so we
//   so we can see the contentView behind it
bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -100.0),

如果您 运行 应用程序,您可能会看到 contentView 仅扩展到 bio 的底部 - 而不是 info 的底部。

如果你这样做:

contentView.clipsToBounds = true

info 可能根本不可见。

检查你的约束,你有:

bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        
// Info table
info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),

它应该在哪里:

// no bio bottom anchor
//bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),

// this is correct
// Info table
info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),

// add this
info.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        

运行 应用程序,您 应该 现在再次看到 info,并且 contentView 延伸到 [=28= 的底部].

假设 bioinfo 的高度组合足够高,需要滚动,您可以撤消“调试/开发”更改,您应该可以开始了。