(本文是自己的翻译,感兴趣的可以查看原文,原文链接:https://skagedal.github.io/2018/08/03/bottom-sheet.html)
BottomDrawer或者叫BottomSheet作为一种用户交互模式已经在iOS使用的越来越频繁,经常出现在苹果自己的APP中,比如Apple Music和Maps。但是尽管如此,却一直没有一个标准组件可以让开发者直接使用。在这篇博客中我会提出一种实现这种UI的方式。这篇博客的目的是解释一种通用做法,如果你愿意,可以查看这个范例项目https://github.com/skagedal/BottomSheet。
我们要创建一个简单的地图应用,自然而然选择使用MKMapView。在地图视图的上面,我们需要一个tableView提供很多城市的快捷选择。我们把这两个主要功能放在一个viewController中,包括MapViewController 和 CountriesTableViewController。
我喜欢用多个viewController的架构,以避免最后把所有的代码都放在一个臃肿的viewController中,所以我们会创建一个BottomSheetContainerViewController,他会承载两个UIViewController,一个作为主控制器,背景的UI(本例中指的是地图),另一个是作为sheet的控制器。在最初的实现里,我们只是把sheetViewController简单的放在mainViewController上的一个固定位置。
现在我们想把tableView向上拖盖住整个屏幕,tableView在苹果的地图应用中有三种位置—全覆盖地图、半覆盖地图以及只展示一个搜索框,我们的demo只会有两种状态:全覆盖和半覆盖。
一种实现这个功能的方式是给tableview添加一个pan gesture,当它被拖动的时候改变它自己的高度约束。但是如果我们看Apple的地图,并想做的和它相似的话,这种方式是存在一些问题的。当向下滚动时,我们需要像在scrollView中看到的相同的橡皮筋效果。当我们向上滚动tableView以覆盖地图时,给定足够的速度,我们希望表格视图在到达顶部后继续滚动。如果你自己尝试了用手势实现的方式,会发现这很不好解决。
我们会使用到完全不同的解决办法。所有的手势识别和拖动都会被scrollView自己处理。要实现这一点,我们要做到以下几点:
1.我们让tableview一直覆盖到状态栏,我们希望它成为底部sheet的顶部位置。
2.我们给定tableview一个400的顶部偏移(最后这个值当然不会是一个固定值,但是现在先让他固定)
3.设置tableview的背景色为clear
4.关闭scrollView的竖向滚动指示器
做完了这些,就可以看到我们想要的效果。我们有一个底部区域可以上下拖动,它的行为也和我们对滚动的预期相同。但是这样也存在问题:
bottom-sheet-2.gif
1.drawer的“background”是tableview cell的背景组成,当向上滚动时,我们会在底部背景看到一个洞。而且顶部也没有我们想要的圆角。
2.我们可以滚动这个drawer到任意位置,并停在任何开始和结束的位置,然而我们需要它停在指定的位置。
3.我们不能和底下的map进行交互了。
背景view
为了给drawer创建一个背景,首先我们让tableview的cells透明。然后我们创建一个新的UIView子类—BottomSheetBackgroundView,让它去实现这个背景的外观。作为第一个版本,我们会使它成为一个纯白色视图。
我们需要一个方法当table view controller滚动时进行通信,以便我们更新此背景视图的位置。我们将通过一组协议来完成这个任务。
protocol BottomSheetDelegate: AnyObject {
func bottomSheet(_ bottomSheet: BottomSheet, didScrollTo contentOffset: CGPoint)
}
protocol BottomSheet: AnyObject {
var bottomSheetDelegate: BottomSheetDelegate? { get set }
}
typealias BottomSheetViewController = UIViewController & BottomSheet
回想一下BottomSheetContainerViewController带有一个UIViewController类型的参数sheetViewController,现在这将是一个BottomSheetViewController
,也就是说,一个遵守BottomSheet协议的UIVIewController。然后BottomSheetContainerViewController设置自己为BottomSheetViewController的bottomSheetDelegate,然后每当移动内容偏移时,它都会调用bottomSheet(_:didScrollTo :)方法。
可是我们应该这样做么?在你查看UIScrollView的API时,你的第一感觉,或许是实现UIScrollViewDelegate的scrollViewDidScroll方法,然而这样也有一个问题,这个方法只有在用户主动滚动的时候才会触发,而不是当以编码的方式使内容偏移更改时。这是设计和UIKit中的一个常见模式,这就是我们需要一个代理的原因,毕竟当你更改属性时,你已经处于程序控制中。就我个人而言,我发现为了跟踪内容偏移,变得很困难。
我们有一个不同的解决办法。我们知道只要发生了滚动,UITableViewController 中的viewDidLayoutSubviews方法会一直被调用。而这就是我们在CountriesTableViewController中要使用的方法。
当代理方法bottomSheet(:didScrollTo:)在内容控制器中被调用时,我们可以更新约束以设置背景的sheet。这样就实现了我们想要的效果。
加上一些效果
接下来要为背景视图添加一些效果,这时你们的设计师可能提供了一些边角或者其中一个处理栏的图片。本例中我们只是添加了corner radius以及border。
由于我们不希望这些边框显示在侧面,我们修改图层的大小,使其位于可见视图之外。虽然我们可以选择设置约束来以这种方式布局背景,但我喜欢我们可以让它成为BottomSheetContainerViewController不必了解的风格实现细节。
bottom-sheet-4.gif
与地图进行交互
接下来,我们要让和地图的交互成为可能。这也很容易实现,只需要在BottomSheetContainerView中实现hitTest(_:with:)方法,现在我们很高兴我们选择将其作为子类UIView实现,而不是将所有内容都放在视图控制器中,因为我们现在已经可以访问我们需要的东西了。
我们使用sheetBackground视图对应于我们希望sheet视图处于活动状态的事实。
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if sheetBackground.bounds.contains(sheetBackground.convert(point, from: self)) {
return sheetView.hitTest(sheetView.convert(point, from: self), with: event)
}
return mainView.hitTest(mainView.convert(point, from: self), with: event)
}
bottom-sheet-5.gif
捕捉
实现捕捉scroll view(比如一个tableview)的方式是实现scrollViewWillEndDragging(_: withVelocity: targetContentOffset:)这个代理方法。targetContentOffset既是输入又是输出。在你不做任何改变的情况下,它会告诉你当滚动结束后的内容偏移会是多少,但如果你改变了它,那就是它停止的位置。这也就是我们要做的事情,如果滚动到不合适的位置,我们需要纠正回来。
override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let targetOffset = targetContentOffset.pointee.y
let pulledUpOffset: CGFloat = 0
let pulledDownOffset: CGFloat = -maxVisibleContentHeight
if (pulledDownOffset...pulledUpOffset).contains(targetOffset) {
if velocity.y < 0 {
targetContentOffset.pointee.y = pulledDownOffset
} else {
targetContentOffset.pointee.y = pulledUpOffset
}
}
}
我们同时设置了table view的decelerationRate为.fast
你可能也注意到了一个问题,那就是在tableview中如果你没有足够多的数据时,比如试着修改CountriesTableViewController中常量的numberOfCountries的值为10,此时不再会滚动到顶部,而是会停留在所有cell可见的位置,这样定位到的就是错误的位置。我们可以通过确保table view的contentSize的最小值来修复这个问题。
添加下面的判断到viewDidLayoutSubviews中:
if tableView.contentSize.height < tableView.bounds.height {
tableView.contentSize.height = tableView.bounds.height
}
结语
我认为我在这里描述的技术是获得理想行为的一种非常好的方法,尽管你必须跳过一些坑。在架构上,我们可能希望改进一些事情。如果我们可以在BottomSheetContainerViewController中包含有关底部sheet的所有内容,那将是很好的 - 目前我们需要处理table view控制器本身的内容。虽然我们可以重构一些东西,然而似乎很难让table view控制器完全不知道它被用作底层的事实,因为我们必须实现一些scrollView的代理方法。
需要注意的是,虽然我已经在许多人使用的生产应用程序中成功使用了这种技术,但我在此处提供的变体不是经过实战检验的代码,可能存在问题。这里的代码行数不多;请随意使用并根据自己的喜好进行调整。并向我发送任何反馈或替代方法。
网友评论