<b>本文讲的是[译]如何在 iOS 上实现类似 Airbnb 中的可展开式菜单,</b>
<b></b>
几个月前,我有机会实现了一个可展开式菜单,效果同知名的 iOS 应用 Airbnb。然后,我认为把它封装为库会更好。现在我想和大家分享用于实现漂亮的滚动驱动动画采用的一些解决方案。
支持的状态
UIScrollView 有一个 bounces: Bool 属性。bounces 能够避免设置 contentOffset 高于/低于限定值。我们需要记住这一点。
UIScrollView contentOffset 演示
我们感兴趣的是用于改变我们菜单状态的属性 contentOffset: CGPoint。监听滚动视图 contentOffset 的主要方式是为对象设置一个代理属性,并实现 scrollViewDidScroll(UIScrollView) 方法。在 Swift 中,没有办法使用 delegate 而不影响其他客户端代码(因为 NSProxy 不可用),因此我打算使用键值监听(KVO)。
我创建了 Observable 泛型类,因此可以监听任何类型。
和两个 Observable 子类:
KVObservable — 用于封装 KVO。
GestureStateObservable — 封装了 target-action 用于监听 UIGestureRecognizer 状态。
为了便于库的测试,我实现了 Scrollable 协议。我也需要采用一种方式让 UIScrollView 监听 contentOffset, contentSize 和 panGestureRecognizer.state。协议一致性是一个很好的方法。除了可以监听库中使用的所有的属性。还包括用于设置带有动画效果的 contentOffset 的 updateContentOffset(CGPoint, animated: Bool) 方法。
我没有使用系统库提供的 UIScrollView 实现的方法 setContentOffset(...) ,因为在我看来,UIKit 动画 API 更加灵活。这里的问题是直接设置 contentOffset 属性并不能使 UIScrollView减速停下来,所以使用没有动画效果的 updateContentOffset(…) 方法设置当前的 contentOffset。
我想要获取可预测的菜单状态。这就是为什么我在 State 结构体中封装了所有可变状态,包括 offset、isExpandedStateAvailable 和 configuration 属性。
offset 仅仅是菜单高度的相反数。我打算使用 offset 来代替 height,因为向下滚动时高度降低,当向上滚动时高度增加。offset 可以使用 *offset = previousOffset + (contentOffset.y — previousContentOffset.y)* 来计算。
isExpandedStateAvailable 属性用于判断 offset 应该赋值为 -normalStateHeight 或 -expandedStateHeight;
configuration 是一个包含菜单高度常量的结构体。
BarController 是用于管理所有计算状态的主要对象,并为调用者提供状态改变。
它传递 stateReducer, configuration 和 stateObserver 作为初始参数。
stateObserver 闭包在 state 属性的 didSet 中被调用中被调用。它通知库的调用者关于状态的改变。
stateReducer 是一个函数,它传入之前的状态,一些滚动上下文参数,并返回一个新状态。通过初始化方法传入参数,用于解耦状态计算和 BarController 对象。
默认的 state reducer 用于计算 contentOffset.y 和 previousContentOffset.y 的差值, 并对每个变换器进行计算。然后返回返回新状态:offset = previousState.offset + deltaY。
库中使用了 3 个变换器来减少状态:
ignoreTopDeltaYTransformer — 确保滚动到 UIScrollView 的顶部被忽略并且不会影响到 BarController 状态;
ignoreBottomDeltaYTransformer — 和 ignoreTopDeltaYTransformer类似,只是滚动到底部;
cutOutStateRangeDeltaYTransformer — 删除那些超过BarController支持的状态(最小值/最大值)限制的 delta Y。
每次 contentOffset 变化时,BarController 调用 stateReducer 并将结果赋值给 state。
到此,该库能够将 contentOffset 的变化转化为内部状态的改变,但是 isExpandedStateAvailable 状态属性此时不能被修改,因为状态状态转变尚未结束。
该 panGestureRecognizer.state 监听出场了:
如果拖动手势在在滚动的上部,或者我们已经处于展开状态,拖动手势将 isExpandedStateAvailable 状态属性设置为 true;
如果状态偏移值达到正常状态,拖动手势变化回调方法就会设置 isExpandedStateAvailable;
拖动手势结束后找到最接近当前状态的偏移量,添加其差值到偏移量上,并调用偏移量到结束状态的动画 updateContentOffset(CGPoint, animated: Bool)。
因此,只有当用户在可用的可滚动区域的顶部滚动时,可展开状态才会生效。如果可展开状态可用并且用户滚动到正常状态之下,此时可展开状态被禁用。如果用户在状态转换期间结束拖动手势,BarController 此时会以动画的方式更新 contentoffset。
BarController 包含 2 个公有方法用于用户设置 UIScrollView。通常情况下,用户使用 set(scrollView: UIScrollView) 方法。也可以使用 preconfigure(scrollView: UIScrollView) 方法,用于设置滚动视图的可视状态与当前 BarController 状态一致。 它被用于滚动视图即将被交换的时候。例如,用户可以采用动画替换当前的滚动视图,并希望在动画开始时将第二滚动视图可视化配置。动画结束后,用户应该调用 set(scrollView: UIScrollView)。如果 UIScrollView 只设置一次,那么 preconfigure(scrollView: UIScrollView) 方法不是必须调用的,因为 set(scrollView: UIScrollView) 是在内部调用的。
preconfigure 方法计算 contentSize 高度和 frame 高度的差值, 并将其赋值给 bottomcontentinset,使其菜单保持可扩展状态,并设置 contentInsets.top 和 scrollIndicatorInsets.top,然后设置初始的 contentOffset 确保新的滚动视图与状态偏移保持一致。
为了通知用户状态变化,BarController 调用注入 stateObserver 方法并传入变化后的 State模型对象。
State 结构体提供了几个公有方法用于从内部状态中读取有用信息:
height()— 返回 offset 的相反数, 菜单的实际高度;
transitionProgress()— 返回从 0 到 2 的改变状态,0 — 简洁状态,1 — 正常状态, 2 — 展开状态;
value(compactNormalRange: ValueRangeType, normalExpandedRange: ValueRangeType) — 根据当前的 StateRange 将转换进度映射为 2 个范围类型之一并返回。
以下为 AirBarExampleApp 中使用 State 的公有方法。airBar.frame.height 根据 height() 动画,backgroundView.alpha 根据 value(...) 动画。这里的背景视图透明会进行 (0, 1) 范围内的差值表示为 compact-normal 的状态, 1 为 normal-expanded 状态。
到此,我已经实现了一个带有可预测状态的漂亮的滚动驱动菜单,并学到了许多使用 UIScrollView 的经验。
以下可以找到本封装库,示例应用和安装指南:
你可以随意使用它。如果遇到任何困难,请告诉我。
你有哪些使用 UIScrollView 及滚动驱动动画经验?欢迎在评论中分享/提问,我很乐意帮忙。
感谢您的阅读!
如果本文对你有帮助, 点击下方的 ,这样其他人也会喜欢它。关注我们更多关于如何构建极好产品的文章。
<b>原文发布时间为:2017年9月4日</b>
<b>本文来自云栖社区合作伙伴掘金,了解相关信息可以关注掘金网站。</b>