ScrollingContentViewController
概述
ScrollingContentViewController 使得创建具有单个滚动内容视图的视图控制器变得简单,或者将现有的静态视图控制器转换为可滚动的视图控制器。最重要的是,它处理了涉及键盘、导航控制器和设备旋转的几个复杂且未记录的边缘情况。
背景
常见的UIKit自动布局任务包括创建一个具有固定布局的视图控制器,但这个布局可能太大,无法适应老式较小尺寸的设备或横屏布局模式,或者在键盘弹出时仍保持可见的屏幕区域。当使用动态字体大小来支持大字体时,问题更为复杂。
例如,考虑这个注册屏幕,它能适配iPhone Xs,但不能适应带有键盘的iPhone SE。
在这种情况下,可以将视图放入一个滚动视图中来处理。您可以在Interface Builder中手动这样做,按照Apple的使用滚动视图文档所述,但需要许多步骤。如果您的视图包含文本字段,您将需要编写代码来调整视图以补偿键盘弹出,如管理键盘中所述。然而,健壮地处理键盘是一个出乎意料复杂的过程,特别是当您的应用程序在一个导航控制器的上下文中显示一系列带键盘的屏幕,或者需要支持设备方向变更时。
为了简化这个任务,ScrollingContentViewController会在运行时将滚动视图插入视图层次结构中,包括所有必要的自动布局约束。
当在故事板中使用时,ScrollingContentViewController公开了一个名为contentView
的输出,您可以将这个输出连接到您希望使其可滚动的视图。这可能是一个视图控制器的根视图或任意子视图。其他一切都是自动处理的,包括响应键盘显示和设备方向变化。
ScrollingContentViewController可以通过故事板或完全通过代码进行配置。最好使用它的方法是继承ScrollingContentViewController
类,而不是UIViewController
。然而,当这不可行时,可以使用一个名为ScrollingContentViewManager
的辅助类来与现有的视图控制器类一起使用。
以下是有关ScrollingContentViewController内部工作原理的解释。
安装
要使用Swift包管理器安装ScrollingContentViewController,将此包URL添加到您的项目中
https://github.com/drewolbrich/ScrollingContentViewController
要使用CocoaPods安装ScrollingContentViewController,将此行添加到您的Podfile中
pod 'ScrollingContentViewController'
要使用Carthage安装,请将此行添加到您的Cartfile中
github "drewolbrich/ScrollingContentViewController"
使用
ScrollingContentViewController
的子类可以使用故事板或代码进行配置。
这个库也可以在不进行子类化的情况下使用,通过组合辅助类ScrollingContentViewManager
来进行。请参考不进行子类化使用方法。
Storyboard
要在Storyboard中配置ScrollingContentViewController
-
创建一个
ScrollingContentViewController
的子类,并在Interface Builder中添加一个使用该类的新视图控制器。或者,如果你有一个现有的继承自UIViewController
的视图控制器,修改你的视图控制器以代替继承自ScrollingContentViewController
。import ScrollingContentViewController class MyViewController: ScrollingContentViewController { // ... }
-
在Interface Builder的概要视图中,控制单击你的视图控制器,将其
contentView
出口连接到视图控制器的根视图或任何你想要使其可滚动的子视图中。 -
如果你的视图控制器定义了
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
分配给视图控制器根视图的子视图,在这种情况下,只有那个子视图将被制成可滚动的。
注意事项
自动布局注意事项
为了使ScrolingContentViewController确定滚动视图内容的高度,内容视图必须包含从内容视图顶部边缘到底部边缘的连续约束和视图链。这同样适用于内容视图的宽度。这与Apple关于如何与滚动视图协同工作的文档描述的方法一致。
如果您没有定义足够的自动布局约束,ScrollingContentViewController将无法确定内容视图的大小,它将无法按预期进行滚动。
如果您希望内容视图扩展以充分利用滚动视图的全视域,请放宽约束以允许这样做。例如,在Interface Builder中,将其中一个高度约束的关系属性更改为“大于等于”。
为了确定滚动视图内容的大小,ScrollingContentViewController创建与滚动视图安全区域宽度和高度大于或等于的关系的宽度和高度约束。这些约束的优先级为500。因此,如果您创建优先级为defaultHigh
(750)或required
(1000)的连续约束链,它们将优先于ScrollingContentViewController内部的最小宽度和高度约束,并且您的视图将无法扩展以填充滚动视图的安全区域。
如果您的视图控制器的大小故意高度受限(例如,仅由优先级为required
的约束组成,缺乏小于或等于关系约束),如果约束与模拟视图的大小不匹配,例如在您切换模拟设备大小时,您可能会在Interface Builder中看到自动布局约束错误。解决此问题的最简单方法是降低其中一条约束的优先级。选择240是一个不错的选择,因为它的值低于默认内容紧密优先级(250),因此它将有助于避免文本字段和标签在没有高度约束的情况下垂直拉伸的不理想行为。
用户内容内联尺寸
如果您不想使用自动布局,则可以使用 intrinsicContentSize
属性替代约束来指定内容视图的大小。
默认的 UIView
内容挤压优先级是 defaultLow
,因此,内容视图的内联内容尺寸通常会被滚动内容视图控制器指定的最小尺寸约束覆盖。如果您想让 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 中,可以设计一个故意比屏幕高度大的视图控制器。为此,将视图控制器的模拟大小更改为自由形式,并调整其高度。当与 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
》这两个类具有以下属性
contentView
滚动内容视图与滚动视图相关的父视图。
当这个属性第一次分配时,它引用的视图会成为scrollView
的父视图,随后该视图会被添加到视图控制器的视图层级中。
如果内容视图已经有了父视图,滚动视图将会替换它成为视图层级中的父视图,并且所有引用内容视图的父视图约束都会被重新指向内容视图。内容视图的宽度和高度约束以及自动调整大小遮罩将被传递到滚动视图中。
如果内容视图没有父视图,滚动视图将作为视图控制器的根视图的父视图,其框架和自动调整大小遮罩被定义为追踪根视图的边界。
如果此后重新分配了《contentView
》属性,新的内容视图将替换旧的内容视图成为滚动视图的子视图,而滚动视图保持不变。
scrollView
将《contentView
》作为其父视图的滚动视图。
您可以安全地更改滚动视图的任何属性。例如,将《keyboardDismissMode
》设置为《interactive
》或《onDrag
`,将允许用户通过拖拽滚动视图来关闭键盘。
滚动视图是用作UIScrollView
子类的实现,它提供了额外的属性和方法,您可以使用它们来修改其行为。
shouldResizeContentViewForKeyboard
一个布尔值,用于确定当键盘出现时内容视图是否被调整大小。
-
true
- 当键盘出现时,内容视图会缩小以适应不被键盘覆盖的滚动视图部分,这取决于内容视图的自动布局约束。通过适当使用约束,这可以更有效地利用减少的屏幕空间。 -
false
- 当键盘出现时,内容视图的大小保持不变。这是默认值。
shouldAdjustAdditionalSafeAreaInsetsForKeyboard
一个布尔值,用于确定当键盘出现时是否调整视图控制器的additionalSafeAreaInsets
属性。
-
true
- 当键盘出现时,视图控制器的additionalSafeAreaInsets
属性会被调整以补偿键盘覆盖的滚动视图部分,确保所有内容视图的内容都可以通过滚动访问。这是默认值。 -
false
- 当键盘出现时,视图控制器的additionalSafeAreaInsets
属性保持不变。如果您更喜欢实现自己的键盘呈现补偿行为,可以分配此值。
滚动视图属性和方法
引用自 ScrollingContentViewController
和 ScrollingContentViewManager
的 scrollView
属性的滚动视图,除了提供通常 UIScrollView
所提供的属性和方法外,还提供以下额外的属性和方法。
visibleScrollMargin
一个浮点值,表示当滚动视图滚动以使第一个响应者可见时应用于第一个响应者视图框架的垂直边距。默认值为 0,与 UIKit 的默认行为一致。
scrollRectToVisible(animated:margin:)
将滚动视图滚动以使矩形可见。
可选的 margin
参数指定了围绕矩形也使其可见的额外边距。如果 margin
未指定或为 nil
,则使用 visibleScrollMargin
的值。
scrollViewToVisible(animated:margin:)
将滚动视图滚动以确保指定的视图可见。
可选的 margin
参数指定了围绕视图也使其可见的额外边距。如果 margin
未指定或为 nil
,则使用 visibleScrollMargin
的值。
scrollFirstResponderToVisible(animated:margin:)
将滚动视图滚动,使第一个响应者可见。如果没有定义第一个响应者,此方法无效果。
可选的 margin
参数指定了第一个响应者周围额外的边距,该边距也会被显示。如果未指定 margin
或 nil
,则将使用 visibilityScrollMargin
的值。
工作原理
视图层次结构
ScrollingContentViewController 在内容视图和其父视图之间插入一个滚动视图,使用 Auto Layout 限制滚动视图的内容布局指南的大小为内容视图的大小。内容视图的大小也被限制为大于或等于滚动视图安全区域的大小,以便可以利用分配给滚动视图的整个屏幕区域。
当内容视图首次分配时,如果它有父视图,滚动视图将替换视图层次结构中的内容视图,并将所有引用内容视图的父视图约束重新分配给内容视图。将内容视图的宽度和高度约束以及自动调整大小掩码转移到滚动视图中。
如果内容视图没有父视图,滚动视图将作为视图控制器的根视图的父视图,其框架和自动调整大小遮罩被定义为追踪根视图的边界。
如果 ScrollingContentViewController 的 contentView
属性引用其根视图,则分配一个新的 UIView
并将其替换为根视图,以便滚动视图具有适当父视图。
内容视图的父视图不一定是视图控制器的根视图,也不必匹配根视图的大小。
有关如何使用 Auto Layout 与滚动视图的详细信息,请参阅 Apple 的 Working with Scroll Views 文档。
额外的安全区域边距
当键盘弹出时,ScrollingContentViewController会修改容器视图控制器的additionalSafeAreaInsets
属性,以补偿与滚动视图重叠的键盘区域,这是苹果在其Managing the Keyboard
文档中推荐的。
尽管ScrollingContentViewController在键盘弹出时修改了additionalSafeAreaInsets
,但在键盘消失时它会恢复到其原始值。这使得additionalSafeAreaInsets
可以用于其他用途,例如自定义工具条。
在开发过程中,也尝试了苹果建议的另一种方法,即修改滚动视图的内容大小。这需要调整滚动视图的ScrollIndicatorInsets
属性来补偿内容大小的变化。遗憾的是,在iPhone Xs的横屏模式下,这样做会产生副作用,导致滚动指示器尴尬地远离屏幕边缘。
键盘大小调整过滤
当文本字段成为第一响应者时,UIKit将显示键盘。如果用户触摸另一个文本字段以更改第一响应者,UIKit可能会根据指定的输入辅助视图调整键盘的高度。这些更改可能会生成一系列不同的键盘高度的keyboardWillShow
通知。
作为一个极端的例子,如果用户通过触摸自动填充输入辅助视图项目来填充电子邮件文本字段,并且这个行为具有副作用,使密码文本字段成为第一响应者,那么在0.1秒的范围内会发布一个keyboardWillHide
通知和两个keyboardWillShow
通知。
如果ScrollingContentViewController针对这些通知逐一做出响应,这将导致在调整键盘高度时伴随的滚动视图动画产生尴尬的不连续性。
为了解决这个问题,ScrollingContentViewController过滤掉了在小时间窗口内发生的一系列通知,只对序列中的最后一个分配的键盘框架进行操作。这似乎与苹果iOS应用的实现方式一致。截至iOS 12,苹果的应用在键盘大小改变后经过短暂的延迟才做出响应,并且不会与键盘的动画一起动态更改其视图。
在设备方向转换期间,在动画开始之前发布一个keyboardWillHide
通知,在动画结束后发布一个keyboardWillShow
通知,尽管键盘在转换期间仍然可见。由于动画的持续时间超过了过滤时间窗口,因此在转换期间需要暂时停止过滤。否则,内容视图将不必要地调整大小。
最后,ScrollingContentViewController 正确处理了滚动视图内容大小或布局因键盘显示或设备方向变化而可能发生变化的场景(尤其是在 shouldResizeContentViewForKeyboard
为 true
时),这将使传递给 scrollRectToVisible
的矩形坐标空间无效(特别是在该方法在键盘变化后自动由 iOS 调用的场景中)。否则,滚动视图可能会滚动不适当的量,或者保留一个超出合法滚动范围的滚动视图内容偏移量。
有关响应键盘可见性变化的更多信息,请参阅 Apple 的 Managing the Keyboard 文档。
处理特殊案例
除了上述的 键盘调整大小过滤 外,ScrollingContentViewController 还解决了几个其他边缘案例
导航控制器
ScrollingContentViewController 正确处理了在导航控制器上下文中推送的视图控制器序列,尤其是在每个视图控制器在 viewWillAppear
中调用文本字段的 becomeFirstResponder
方法时,这样在视图控制器转换期间键盘保持可见。
设备方向变化
当发生设备方向变化时,ScrollingContentViewController 通过将滚动视图左上角固定在原地并在同时防止超出范围的滚动视图内容偏移量来改进默认滚动视图行为。这与许多 Apple 的 iOS 中的应用程序的行为相匹配。
键盘关闭模式
当将UIScrollView.alwaysBounceVertical
设置为非UIScrollView.keyboardDismissMode
(即.non)时,ScrollingContentViewController会在键盘弹出时自动启用垂直滚动。这使得即使在视图太短无法正常滚动的情况下,也可以关闭键盘。
任意滚动视口大小
ScrollingContentViewController正确处理了下述情况:当滚动视图并未覆盖整个屏幕,此时它可能只与键盘部分重叠。
文本字段动画残留物
自iOS 12以来,如果用户连续点击自定义文本字段,UIKit可能会不自然地动画化文本字段中的文本。ScrollingContentViewController会抑制这种动画。
许可证
本项目遵循MIT开源许可证。有关完整条款,请参阅文件LICENSE。