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
创建 ScrollingContentViewController
的子类,并在 Interface Builder 中添加一个具有该类的新视图控制器。 或者,如果您有现有的视图控制器是 UIViewController
的子类,请修改您的视图控制器以改为子类化 ScrollingContentViewController
。
import ScrollingContentViewController
class MyViewController: ScrollingContentViewController {
// ...
}
在 Interface Builder 的大纲视图中,按住 Control 键单击您的视图控制器,并将其 contentView
outlet 连接到您的视图控制器的根视图或您想要使其可滚动的任何其他子视图。
如果您的视图控制器定义了 viewDidLoad
方法,如果您尚未这样做,请调用 super.viewDidLoad
。
override func viewDidLoad() {
super.viewDidLoad()
// ...
}
在运行时,ScrollingContentViewController
属性 contentView
现在将引用您在 Interface Builder 中布局的控件的父视图。 此父视图将不再被 view
属性引用,而是引用滚动内容视图后面的空根视图。 如果有必要,请修改您的代码以反映此更改。
您的内容视图现在将滚动,前提是您确保内容视图的 Auto Layout 约束 充分定义其大小,并且此大小大于安全区域。
要以编程方式集成 ScrollingContentViewController
子类化 ScrollingContentViewController
而不是 UIViewController
。
import ScrollingContentViewController
class MyViewController: ScrollingContentViewController {
// ...
}
在您的视图控制器的 viewDidLoad
方法中,将新视图分配给 contentView
属性。 将所有控件添加到此视图,而不是引用 view
属性,以便它们可以自由滚动。 视图控制器的根视图由其 view
属性引用,现在充当滚动内容视图后面的背景视图。
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
contentView = UIView()
// Add all controls to contentView instead of view.
// ...
}
您还可以将 contentView
分配给您的视图控制器根视图的子视图,在这种情况下,只有该子视图将变得可滚动。
为了让 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
。
内容视图位于滚动视图的安全区域内。 因此,内容视图的背景颜色不会延伸到状态栏、主页指示器、导航栏或工具栏下方。
要指定延伸到屏幕边缘的背景颜色
将视图控制器的根视图的背景颜色设置为所需的颜色。 此视图将在透明的滚动视图后面可见。
将内容视图的背景颜色设置为 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()
}
}
StoryboardExample - 在故事板中配置 ScrollingContentViewController
的示例。
CodeExample - 仅使用代码的示例。
ManagerExample - 使用 ScrollingContentViewManager
和类组合而不是子类化 ScrollingContentViewController
的示例。
SequenceExample - 在导航控制器上下文中,一系列推送的滚动视图控制器带有键盘的示例。
ReassignExample - 动态重新分配 contentView
的示例。
ScrollingContentViewController
和 ScrollingContentViewManager
类共享以下属性:
滚动到滚动视图的内容视图父视图。
首次分配此属性时,它引用的视图将成为 scrollView
的父视图,然后将其添加到视图控制器的视图层次结构中。
如果内容视图已经有一个父视图,则滚动视图将替换视图层次结构中的父视图,并且引用内容视图的所有父视图的约束都将重新定向到内容视图。内容视图的宽度和高度约束以及自动调整大小的掩码将转移到滚动视图。
如果内容视图没有父视图,则滚动视图将成为视图控制器根视图的父视图,并且其框架和自动调整大小的掩码被定义为跟踪根视图的边界。
如果以后重新分配 contentView
属性,则新的内容视图将替换旧的内容视图作为滚动视图的子视图,否则滚动视图将保持未修改状态。
contentView
所属的滚动视图。
您可以安全地修改滚动视图的任何属性。 例如,将 keyboardDismissMode
设置为 interactive
或 onDrag
将允许用户通过拖动滚动视图来关闭键盘。
该滚动视图被实现为 UIScrollView
的子类,它提供了 附加属性和方法,您可以使用这些属性和方法来修改其行为。
一个布尔值,用于确定在显示键盘时是否调整内容视图的大小。
true
- 当显示键盘时,内容视图缩小以适合滚动视图中未被键盘重叠的部分,以内容视图的自动布局约束允许的程度为准。 通过适当使用约束,这可以更有效地利用缩小的屏幕空间。
false
- 当显示键盘时,内容视图的大小保持不变。 这是默认值。
一个布尔值,用于确定在显示键盘时是否调整视图控制器的 additionalSafeAreaInsets
属性。
true
- 当显示键盘时,视图控制器的 additionalSafeAreaInsets
属性将进行调整,以补偿被键盘重叠的滚动视图的部分,从而确保可以通过滚动访问内容视图的所有内容。 这是默认值。
false
- 当显示键盘时,视图控制器的 additionalSafeAreaInsets
属性保持不变。 如果您希望实现自己的键盘显示补偿行为,请分配此值。
除了 UIScrollView
通常提供的属性和方法之外,ScrollingContentViewController
和 ScrollingContentViewManager
的 scrollView
属性引用的滚动视图还提供了以下附加属性和方法:
一个浮点值,表示在滚动滚动视图以使第一响应者可见时,应用于第一响应者视图框架的垂直边距。 默认值为 0,与 UIKit 的默认行为匹配。
滚动滚动视图以使矩形可见。
可选的 margin
参数指定矩形周围的额外边距,该边距也将变为可见。 如果未指定 margin
或 nil
,则将使用 visibilityScrollMargin
的值。
滚动滚动视图以使指定的视图可见。
可选的 margin
参数指定视图周围的额外边距,该边距也将变为可见。 如果未指定 margin
或 nil
,则将使用 visibilityScrollMargin
的值。
滚动滚动视图以使第一响应者可见。 如果未定义第一响应者,则此方法无效。
可选的 margin
参数指定第一响应者周围的额外边距,该边距也将变为可见。 如果未指定 margin
或 nil
,则将使用 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 正确处理了以下情况:滚动视图内容的大小或布局的更改可能因键盘显示或设备方向更改而发生(特别是当 shouldResizeContentViewForKeyboard
为 true
时),从而导致传递给 scrollRectToVisible
的矩形的坐标空间失效(最重要的是,在键盘更改后 iOS 自动调用该方法的情况下),否则会导致滚动视图滚动不合适的量,或者使滚动视图的内容偏移量超出合法的滚动范围。
有关响应键盘可见性更改的更多信息,请参阅 Apple 的 管理键盘 文档。
除了上面的 键盘大小调整过滤,ScrollingContentViewController 还解决了其他一些极端情况。
ScrollingContentViewController 正确处理了导航控制器上下文中推送的视图控制器序列,特别是在每个视图控制器都在 viewWillAppear
中调用文本字段的 becomeFirstResponder
方法,从而使键盘在视图控制器转换过程中保持可见的情况下。
当设备方向发生更改时,ScrollingContentViewController 通过将滚动视图的左上角固定到位,同时防止超出范围的内容偏移量,从而改进了默认的滚动视图行为。这与许多 Apple iOS 应用程序的行为一致。
如果 UIScrollView.keyboardDismissMode
设置为除 none
之外的任何值,则 ScrollingContentViewController 会在键盘显示时自动启用 UIScrollView.alwaysBounceVertical
,因此即使视图太短而通常不允许滚动,也可以关闭键盘。
ScrollingContentViewController 正确处理了滚动视图未覆盖屏幕全部范围的情况,在这种情况下,它可能只与键盘部分相交。
从 iOS 12 开始,如果用户点击一系列自定义文本字段,UIKit 可能会笨拙地对文本字段的文本进行动画处理。ScrollingContentViewController 抑制了此动画。
本项目根据 MIT 开源许可证的条款获得许可。请参阅文件 LICENSE 以获取完整条款。