iOS创建一个底部sheet(类似iPhone地图)

(本文是自己的翻译,感兴趣的可以查看原文,原文链接: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中,包括MapViewControllerCountriesTableViewController

我喜欢用多个viewController的架构,以避免最后把所有的代码都放在一个臃肿的viewController中,所以我们会创建一个BottomSheetContainerViewController,他会承载两个UIViewController,一个作为主控制器,背景的UI(本例中指的是地图),另一个是作为sheet的控制器。在最初的实现里,我们只是把sheetViewController简单的放在mainViewController上的一个固定位置。

bottom-sheet-1.gif

现在我们想把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设置自己为BottomSheetViewControllerbottomSheetDelegate,然后每当移动内容偏移时,它都会调用bottomSheet(_:didScrollTo :)方法。

可是我们应该这样做么?在你查看UIScrollView的API时,你的第一感觉,或许是实现UIScrollViewDelegate的scrollViewDidScroll方法,然而这样也有一个问题,这个方法只有在用户主动滚动的时候才会触发,而不是当以编码的方式使内容偏移更改时。这是设计和UIKit中的一个常见模式,这就是我们需要一个代理的原因,毕竟当你更改属性时,你已经处于程序控制中。就我个人而言,我发现为了跟踪内容偏移,变得很困难。

我们有一个不同的解决办法。我们知道只要发生了滚动,UITableViewController 中的viewDidLayoutSubviews方法会一直被调用。而这就是我们在CountriesTableViewController中要使用的方法。
当代理方法bottomSheet(:didScrollTo:)在内容控制器中被调用时,我们可以更新约束以设置背景的sheet。这样就实现了我们想要的效果。

bottom-sheet-3.gif

加上一些效果

接下来要为背景视图添加一些效果,这时你们的设计师可能提供了一些边角或者其中一个处理栏的图片。本例中我们只是添加了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

bottom-sheet-6.gif

你可能也注意到了一个问题,那就是在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的代理方法。
需要注意的是,虽然我已经在许多人使用的生产应用程序中成功使用了这种技术,但我在此处提供的变体不是经过实战检验的代码,可能存在问题。这里的代码行数不多;请随意使用并根据自己的喜好进行调整。并向我发送任何反馈或替代方法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352