ScrollingContentViewController

License

概述

ScrollingContentViewController 可以轻松创建具有单个滚动内容视图的视图控制器,或者将现有的静态视图控制器转换为滚动的视图控制器。 最重要的是,它可以处理一些涉及键盘、导航控制器和设备旋转的棘手未公开的边缘情况。

背景

一个常见的 UIKit Auto Layout 任务是创建一个具有固定布局的视图控制器,该布局太大,无法容纳较旧、较小的设备,或者横向方向的设备,或者键盘显示时屏幕的可见区域。 当使用 动态类型来支持大字体时,问题会更加复杂。

例如,考虑这个注册屏幕,它适合 iPhone Xs,但不适合带有键盘的 iPhone SE

这种情况可以通过将视图嵌套在滚动视图中来处理。 您可以在 Interface Builder 中手动执行此操作,如 Apple 的 使用滚动视图文档中所述,但需要很多步骤。 如果您的视图包含文本字段,则需要编写代码来调整视图以补偿显示的键盘,如 管理键盘中所述。 然而,稳健地处理键盘 非常复杂,尤其是当您的应用程序在导航控制器的上下文中呈现一系列带有键盘的屏幕,或者需要设备方向支持时。

为了简化这项任务,ScrollingContentViewController 会在运行时将滚动视图插入到视图层次结构中,以及所有必要的 Auto Layout 约束。

在故事板中使用时,ScrollingContentViewController 会公开一个名为 contentView 的 outlet,您可以将其连接到您想要使其可滚动的视图。 这可能是视图控制器的根视图或任意子视图。 其他一切都会自动处理,包括响应键盘显示和设备方向更改。

ScrollingContentViewController 可以使用故事板或完全在代码中配置。 使用它的最简单方法是子类化 ScrollingContentViewController 类而不是 UIViewController。 但是,如果这不是一种选择,则可以使用名为 ScrollingContentViewManager 的 helper 类与您现有的视图控制器类组合使用。

下面提供了关于 ScrollingContentViewController 内部工作原理的解释。

安装

要使用 Swift Package Manager 安装 ScrollingContentViewController,请将此包 URL 添加到您的项目中

https://github.com/drewolbrich/ScrollingContentViewController

要使用 CocoaPods 安装 ScrollingContentViewController,请将此行添加到您的 Podfile

pod 'ScrollingContentViewController'

要使用 Carthage 安装,请将其添加到您的 Cartfile

github "drewolbrich/ScrollingContentViewController"

用法

ScrollingContentViewController 的子类可以使用 故事板或在 代码中配置。

该库也可以不使用子类化来使用,而是组合 helper 类 ScrollingContentViewManager。 请参阅不使用子类的用法

故事板

要在故事板中配置 ScrollingContentViewController

  1. 创建 ScrollingContentViewController 的子类,并在 Interface Builder 中添加一个具有该类的新视图控制器。 或者,如果您有现有的视图控制器是 UIViewController的子类,请修改您的视图控制器以改为子类化 ScrollingContentViewController

    import ScrollingContentViewController
    
    class MyViewController: ScrollingContentViewController {
    
        // ...
    
    }
  2. 在 Interface Builder 的大纲视图中,按住 Control 键单击您的视图控制器,并将其 contentView outlet 连接到您的视图控制器的根视图或您想要使其可滚动的任何其他子视图。

  3. 如果您的视图控制器定义了 viewDidLoad 方法,如果您尚未这样做,请调用 super.viewDidLoad

    override func viewDidLoad() {
        super.viewDidLoad()
    
        // ...
    }
  4. 在运行时,ScrollingContentViewController 属性 contentView 现在将引用您在 Interface Builder 中布局的控件的父视图。 此父视图将不再被 view 属性引用,而是引用滚动内容视图后面的空根视图。 如果有必要,请修改您的代码以反映此更改。

您的内容视图现在将滚动,前提是您确保内容视图的 Auto Layout 约束 充分定义其大小,并且此大小大于安全区域。

代码

要以编程方式集成 ScrollingContentViewController

  1. 子类化 ScrollingContentViewController 而不是 UIViewController

    import ScrollingContentViewController
    
    class MyViewController: ScrollingContentViewController {
    
        // ...
    
    }
  2. 在您的视图控制器的 viewDidLoad 方法中,将新视图分配给 contentView 属性。 将所有控件添加到此视图,而不是引用 view 属性,以便它们可以自由滚动。 视图控制器的根视图由其 view 属性引用,现在充当滚动内容视图后面的背景视图。

    override func viewDidLoad() {
        super.viewDidLoad()
    
        view.backgroundColor = .systemBackground
    
        contentView = UIView()
    
        // Add all controls to contentView instead of view.
        // ...
    }

您还可以将 contentView 分配给您的视图控制器根视图的子视图,在这种情况下,只有该子视图将变得可滚动。

注意事项

Auto Layout 注意事项

为了让 ScrollingContentViewController 确定滚动视图内容的高度,内容视图必须包含从内容视图的顶部边缘到其底部边缘的约束和视图的不间断链。 内容视图的宽度也是如此。 这与 Apple 的 使用滚动视图文档中描述的方法一致。

如果您没有定义足够的 Auto Layout 约束,ScrollingContentViewController 将无法确定您的内容视图的大小,并且它将不会按预期滚动。

如果您希望您的内容视图拉伸以充分利用滚动视图的完整可见区域,请放宽您的约束以允许这样做。 例如,在 Interface Builder 中,将您的一个高度约束的 Relation 属性更改为 Greater Than or Equal。

为了确定滚动视图的内容大小,ScrollingContentViewController 创建宽度和高度约束,其关系大于或等于滚动视图的安全区域的宽度和高度。 这些约束的优先级为 500。 因此,如果您创建一个优先级为 defaultHigh (750) 或 required (1000) 的不间断约束链,它们将优先于 ScrollingContentViewController 的内部最小宽度和高度约束,并且您的内容视图将不会拉伸以填充滚动视图的安全区域。

如果您的视图控制器的大小受到高度约束(例如,完全由具有 required 优先级且缺少 greaterThanOrEqual 关系约束的约束组成),如果在 Interface Builder 中约束与视图的模拟大小不匹配,您可能会看到 Auto Layout 约束错误,例如,当您在模拟设备大小之间切换时。 解决此问题的最简单方法是降低其中一个约束的优先级。 值 240 是一个不错的选择,因为它低于默认的内容吸附优先级 (250),因此,它将有助于避免文本字段和没有高度约束的标签垂直拉伸的不良行为。

固有内容大小

如果您不想使用 Auto Layout,则可以使用 intrinsicContentSize 而不是约束来指定内容视图的大小。

默认的 UIView 内容吸附优先级为 defaultLow,因此,内容视图的固有内容大小通常会被 ScrollingContentViewController 分配的最小大小约束覆盖。 如果您希望 intrinsicContentSize 优先于这些约束,请将内容视图的内容吸附优先级设置为 required

更改背景颜色

内容视图位于滚动视图的安全区域内。 因此,内容视图的背景颜色不会延伸到状态栏、主页指示器、导航栏或工具栏下方。

要指定延伸到屏幕边缘的背景颜色

  1. 将视图控制器的根视图的背景颜色设置为所需的颜色。 此视图将在透明的滚动视图后面可见。

  2. 将内容视图的背景颜色设置为 nil,使其也透明。

例如

view.backgroundColor = UIColor(red: 1, green: 0.949, blue: 0.788, alpha: 1)
contentView.backgroundColor = nil

调整内容视图的大小

如果您对内容视图进行更改,修改了其大小,您必须调用滚动视图的 setNeedsLayout 方法,否则滚动视图的内容大小将不会更新以反映大小的更改,并且您的视图可能无法正确滚动。

例如,在更新视图的 NSLayoutConstraint.constant 属性后,您可以像这样动画更改:

UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0,
        options: [], animations: {
    self.scrollView.setNeedsLayout()
    self.scrollView.layoutIfNeeded()
}, completion: nil)

超大视图控制器

在 Interface Builder 中,可以设计一个故意大于屏幕高度的视图控制器。为此,请将视图控制器的模拟大小更改为 Freeform 并调整其高度。与 ScrollingContentViewController 一起使用时,视图控制器的超大内容视图将自由滚动,前提是其约束要求它大于屏幕。

不使用子类的用法

当无法选择子类化 ScrollingContentViewController 时,可以使用辅助类 ScrollingContentViewManager 与您的视图控制器组合。

import ScrollingContentViewController

class MyViewController: UIViewController {

    lazy var scrollingContentViewManager = ScrollingContentViewManager(hostViewController: self)

    @IBOutlet weak var contentView: UIView!

    override func loadView() {
        // Load all controls and connect all outlets defined by Interface Builder.
        super.loadView()

        scrollingContentViewManager.loadView(forContentView: contentView)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // When ScrollingContentViewManager.contentView is first assigned, this has the
        // side effect of adding a scroll view to the content view's superview, and
        // adding the content view to the scroll view.
        scrollingContentViewManager.contentView = contentView

        // Set the content view's background color to transparent so the root view is
        // visible behind it.
        contentView.backgroundColor = nil
    }

    // Note: This method is not strictly required, but logs a warning if the content
    // view's size is undefined.
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        scrollingContentViewManager.viewWillAppear(animated)
    }

    // Note: This is only required in apps that support device orientation changes.
    override func viewWillTransition(to size: CGSize,
            with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        scrollingContentViewManager.viewWillTransition(to: size, with: coordinator)
    }

    // Note: This is only required in apps with navigation controllers that are used to
    // push sequences of view controllers with text fields that become the first
    // responder in `viewWillAppear`.
    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()

        scrollingContentViewManager.viewSafeAreaInsetsDidChange()
    }

}

ScrollingContentViewManager 类支持与 ScrollingContentViewController 相同的所有 属性方法

ScrollingContentViewManager 还可以用于以编程方式创建滚动视图控制器

import ScrollingContentViewController

class MyViewController: UIViewController {

    lazy var scrollingContentViewManager = ScrollingContentViewManager(hostViewController: self)

    let contentView = UIView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Populate your content view here.
        // ...

        // When ScrollingContentViewManager.contentView is first assigned, this has the
        // side effect of adding a scroll view to the view controller's root view, and
        // adding the content view to the scroll view.
        scrollingContentViewManager.contentView = contentView
    }

    // Note: This method is not strictly required, but logs a warning if the content
    // view's size is undefined.
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        scrollingContentViewManager.viewWillAppear(animated)
    }

    // Note: This is only required in apps that support device orientation changes.
    override func viewWillTransition(to size: CGSize,
            with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        scrollingContentViewManager.viewWillTransition(to: size, with: coordinator)
    }

    // Note: This is only required in apps with navigation controllers that are used to
    // push sequences of view controllers with text fields that become the first
    // responder in `viewWillAppear`.
    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()

        scrollingContentViewManager.viewSafeAreaInsetsDidChange()
    }

}

示例

视图控制器属性

ScrollingContentViewControllerScrollingContentViewManager 类共享以下属性:

contentView

滚动到滚动视图的内容视图父视图。

首次分配此属性时,它引用的视图将成为 scrollView 的父视图,然后将其添加到视图控制器的视图层次结构中。

如果内容视图已经有一个父视图,则滚动视图将替换视图层次结构中的父视图,并且引用内容视图的所有父视图的约束都将重新定向到内容视图。内容视图的宽度和高度约束以及自动调整大小的掩码将转移到滚动视图。

如果内容视图没有父视图,则滚动视图将成为视图控制器根视图的父视图,并且其框架和自动调整大小的掩码被定义为跟踪根视图的边界。

如果以后重新分配 contentView 属性,则新的内容视图将替换旧的内容视图作为滚动视图的子视图,否则滚动视图将保持未修改状态。

scrollView

contentView 所属的滚动视图。

您可以安全地修改滚动视图的任何属性。 例如,将 keyboardDismissMode 设置为 interactiveonDrag 将允许用户通过拖动滚动视图来关闭键盘。

该滚动视图被实现为 UIScrollView 的子类,它提供了 附加属性和方法,您可以使用这些属性和方法来修改其行为。

shouldResizeContentViewForKeyboard

一个布尔值,用于确定在显示键盘时是否调整内容视图的大小。

shouldAdjustAdditionalSafeAreaInsetsForKeyboard

一个布尔值,用于确定在显示键盘时是否调整视图控制器的 additionalSafeAreaInsets 属性。

滚动视图属性和方法

除了 UIScrollView 通常提供的属性和方法之外,ScrollingContentViewControllerScrollingContentViewManagerscrollView 属性引用的滚动视图还提供了以下附加属性和方法:

visibilityScrollMargin

一个浮点值,表示在滚动滚动视图以使第一响应者可见时,应用于第一响应者视图框架的垂直边距。 默认值为 0,与 UIKit 的默认行为匹配。

scrollRectToVisible(animated:margin:)

滚动滚动视图以使矩形可见。

可选的 margin 参数指定矩形周围的额外边距,该边距也将变为可见。 如果未指定 marginnil,则将使用 visibilityScrollMargin 的值。

scrollViewToVisible(animated:margin:)

滚动滚动视图以使指定的视图可见。

可选的 margin 参数指定视图周围的额外边距,该边距也将变为可见。 如果未指定 marginnil,则将使用 visibilityScrollMargin 的值。

scrollFirstResponderToVisible(animated:margin:)

滚动滚动视图以使第一响应者可见。 如果未定义第一响应者,则此方法无效。

可选的 margin 参数指定第一响应者周围的额外边距,该边距也将变为可见。 如果未指定 marginnil,则将使用 visibilityScrollMargin 的值。

工作原理

视图层次结构

ScrollingContentViewController 在内容视图和其父视图之间插入一个滚动视图,使用自动布局来约束滚动视图的内容布局指南以适应内容视图的大小。 内容视图的大小也被约束为大于或等于滚动视图的安全区域的大小,因此它可以利用分配给滚动视图的屏幕的全部区域。

首次分配内容视图时,如果它具有父视图,则滚动视图将替换视图层次结构中的父视图,并且引用内容视图的所有父视图的约束都将重新定向到内容视图。 内容视图的宽度和高度约束以及自动调整大小的掩码将转移到滚动视图。

如果内容视图没有父视图,则滚动视图将成为视图控制器根视图的父视图,并且其框架和自动调整大小的掩码被定义为跟踪根视图的边界。

如果 ScrollingContentViewController 的 contentView 属性引用其根视图,则会分配一个新的 UIView 并替换它作为根视图,以便滚动视图具有一个适当的视图可以作为父视图。

内容视图的父视图不一定是视图控制器的根视图,也不必与根视图的大小匹配。

有关如何将滚动视图与自动布局一起使用的详细说明,请参阅 Apple 的 使用滚动视图 文档。

附加安全区域内插

当显示键盘时,ScrollingContentViewController 修改容器视图控制器的 additionalSafeAreaInsets 属性,以补偿键盘重叠滚动视图的区域,如 Apple 的 管理键盘 文档中所建议的。

尽管 ScrollingContentViewController 在显示键盘时会修改 additionalSafeAreaInsets,但在键盘关闭时会将其恢复为其原始值。 这允许将 additionalSafeAreaInsets 用于其他目的,例如自定义工具调色板。

在开发过程中,还尝试了 Apple 建议的另一种方法,即修改滚动视图的内容大小。 这需要调整滚动视图的 scrollIndicatorInsets 属性以补偿内容大小的更改。 不幸的是,在横向模式下的 iPhone Xs 上,这样做会产生奇怪的副作用,即将滚动指示器从屏幕边缘移开。

键盘大小调整过滤

当文本字段成为第一响应者时,UIKit 会显示键盘。 如果用户点击另一个文本字段,更改第一响应者,则如果指定了输入附件视图,UIKit 可能会调整键盘的高度。 这些更改可能会生成一系列 keyboardWillShow 通知,每个通知都有不同的键盘高度。

作为一个极端的例子,如果用户通过点击自动填充输入附件视图项来填充电子邮件文本字段,并且此操作的副作用是导致密码文本字段成为第一响应者,则将在 0.1 秒的时间跨度内发布一个 keyboardWillHide 通知和两个 keyboardWillShow 通知。

如果 ScrollingContentViewController 单独响应每个通知,这将导致滚动视图动画出现尴尬的不连续性,该动画伴随键盘高度的变化。

为了解决这个问题,ScrollingContentViewController 过滤掉在很短的时间窗口内发生的通知序列,仅对序列中最终分配的键盘框架起作用。 这似乎与 Apple iOS 应用的实现方式一致。 从 iOS 12 开始,Apple 的应用仅在短暂延迟后才响应键盘大小的变化,并且不会与其视图动画同步键盘的动画。

在设备方向转换期间,动画开始之前会发布一个 keyboardWillHide 通知,动画结束后会发布一个 keyboardWillShow 通知,即使在转换期间键盘仍然可见。 因为动画的持续时间超过了过滤时间窗口,因此有必要在转换期间暂时暂停过滤。 否则,内容视图将不必要地调整大小。

最后,ScrollingContentViewController 正确处理了以下情况:滚动视图内容的大小或布局的更改可能因键盘显示或设备方向更改而发生(特别是当 shouldResizeContentViewForKeyboardtrue 时),从而导致传递给 scrollRectToVisible 的矩形的坐标空间失效(最重要的是,在键盘更改后 iOS 自动调用该方法的情况下),否则会导致滚动视图滚动不合适的量,或者使滚动视图的内容偏移量超出合法的滚动范围。

有关响应键盘可见性更改的更多信息,请参阅 Apple 的 管理键盘 文档。

处理的特殊情况

除了上面的 键盘大小调整过滤,ScrollingContentViewController 还解决了其他一些极端情况。

导航控制器

ScrollingContentViewController 正确处理了导航控制器上下文中推送的视图控制器序列,特别是在每个视图控制器都在 viewWillAppear 中调用文本字段的 becomeFirstResponder 方法,从而使键盘在视图控制器转换过程中保持可见的情况下。

设备方向更改

当设备方向发生更改时,ScrollingContentViewController 通过将滚动视图的左上角固定到位,同时防止超出范围的内容偏移量,从而改进了默认的滚动视图行为。这与许多 Apple iOS 应用程序的行为一致。

keyboardDismissMode

如果 UIScrollView.keyboardDismissMode 设置为除 none 之外的任何值,则 ScrollingContentViewController 会在键盘显示时自动启用 UIScrollView.alwaysBounceVertical,因此即使视图太短而通常不允许滚动,也可以关闭键盘。

任意滚动视图大小

ScrollingContentViewController 正确处理了滚动视图未覆盖屏幕全部范围的情况,在这种情况下,它可能只与键盘部分相交。

文本字段动画伪影

从 iOS 12 开始,如果用户点击一系列自定义文本字段,UIKit 可能会笨拙地对文本字段的文本进行动画处理。ScrollingContentViewController 抑制了此动画。

许可证

本项目根据 MIT 开源许可证的条款获得许可。请参阅文件 LICENSE 以获取完整条款。