原文:Google Material Design Tutorial for iOS: Getting Started
作者:Nick Bonatsakis
译者:kmyhy
看到本文的标题,你是不是会奇怪:Google 材料设计和 iOS 怎么会联系在一起呢?毕竟材料设计是因 Google 而闻名,尤其是在 Android 系统上。
但是,Google 有着将材料设计扩展到其它平台的野心,包括 iOS。Google 甚至将在 iOS 上支持材料设计的组件进行了开源。
在本教程中,你将初步了解材料设计并编写一个简单 app,用 newsapi.org API 显示来自不同来源的文章。
通过谷歌的 iOS 材料设计组件,你将对这个 app 进行美化,用一个 Flexible Header、标准材料颜色、typography、滑动 tab 和 cards with ink。
开始
下载开始项目News Ink,顺便熟悉一下它。
你会发现项目使用了 CocoaPods。在终端中,进入项目的根目录,执行 pod install。
如果你不熟悉 CocoaPods,我们有一个入门教程让你熟悉这个依赖管理器。
在你开始编写这个 app 之前,你需要在 https://newsapi.org/ 注册,获得一个 newsapi.org key。
拿到 key 之后,打开 NewsClient.swift 将 key 粘贴到这个常量中:
static let apiKey = "REPLACE_WITH_NEWSAPIORG_KEY"
然后 build & run。
这里没有什么值得一提的东东:除了一个包含图文的简单列表以外。你可以点击 cell 跳到全文的 web view 中,但那是后面的事了。
在开始编码之前,先来学一点材料设计。
材料设计
谷歌在 2014 年推出了材料设计,它马上就变成了超越所有 Google web 和移动产品的 UI/UX 标准。谷歌材料设计指南是一个不错的开始,我建议你在继续往下读之前好好看一看它。
但材料设计有什么过人之处,尤其是为什么要在 iOS app 中使用它?毕竟,苹果有自己的一套 UI/UX 标准,即所谓的人机交互指南。
答案是我们使用的周边设备是在是太多了。从移动电话到平板电脑,再到 PC,再到电视,我们的日常生活就是从一个屏幕到另一个屏幕。能够跨越所有屏幕和设备的单一界面设计,能够提供平滑的用户体验,大大减少从一个设备到另一个设备的学习负担。
这里使用了一个人人皆知的比喻,用材料,也就是纸张来比喻每种新屏幕。同时,设计指南是高度强制性的、具体的以及被平台级别的 UI 组件所支持的,用这个设计指南构建的 app 会很容易对齐一致。
材料设计规范并没有说明只能用于 Google 平台。一个统一的设计系统的所有益处都是和 iOS 针锋相对的的,因为它们是不同的平台。如果你将苹果的人机交互指南和谷歌的材料设计指南进行比较,你会发现材料设计规范更具体和更强制。相反,苹果的指南完全不做强制规定,尤其是可视化相关的,比如字体、颜色和布局。
谷歌决心让材料设计作为一种跨平台标准,它制作了一个平台适配指南,向你演示实现材料设计的方法,对所有平台开放。
有这么多的预备知识!放心,这些都……不重要。现在正式学习 Google 材料设计的 iOS 组件。
材料设计在 iOS 上的实践
到这一节结束时,你的 app 在打开时会显示一个大大的 header,包括一个全出血的照片背景以及很大的商标文字。当你滚动的时候,照片会在滚动中淡出,同时商标会缩小直到整个 header 变成一个标准的 navigation bar。
一开始,没有 navigation bar、标题或者其它告诉用户他们所用的 app 的任何东西。你将用Flexible Header、hero image 和流式滚动效果来引导 app。
添加一个 App Bar
你需要添加的第一个最酷的材料设计组件是 App Bar。这里有大量干货,因为 App Bar 由 3 个组件组成:Flexible Header、Header Stack View 和 Navigation Bar。每个组件都是很强大的,但是你会看到,当它们组合在一起时,你会得到一些特别的东西。
打开 HeroHeaderView.swift。为了简单,你将构建一个 UIView 子类,用于包含所有组成这个 flexible header 的子视图,以及所有这些子视图根据滚动位置改变的逻辑。
首先在 HeroHeaderView 类中添加一个结构体:
struct Constants {
static let statusBarHeight: CGFloat = UIApplication.shared.statusBarFrame.height
static let minHeight: CGFloat = 44 + statusBarHeight
static let maxHeight: CGFloat = 400.0
}
这里定义了一些常量,你将在编写 Head view 的时候用到它们。
statusBarHeight 代表了状态栏的高度,minHeight 和 maxHeight 代表了 header 的最小(完全收起)高度和最大(完全展开)的高度。
在 HomeHeaderView 中添加下列属性:
// MARK: Properties
let imageView: UIImageView = {
let imageView = UIImageView(image: #imageLiteral(resourceName: "img-hero"))
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
return imageView
}()
let titleLabel: UILabel = {
let label = UILabel()
label.text = NSLocalizedString("News Ink", comment: "")
label.textAlignment = .center
label.textColor = .white
label.shadowOffset = CGSize(width: 1, height: 1)
label.shadowColor = .darkGray
return label
}()
这里没有太复杂的东西;添加了一张 UIImageView 放 header 的背景图片,以及一个 UILabel 用于显示 app 的商标文字。
然后,在 HomeHeaderView 初始化方法中添加如下代码,用于添加 subview 和定义布局:
// MARK: Init
// 1
init() {
super.init(frame: .zero)
autoresizingMask = [.flexibleWidth, .flexibleHeight]
clipsToBounds = true
configureView()
}
// 2
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: View
// 3
func configureView() {
backgroundColor = .darkGray
addSubview(imageView)
addSubview(titleLabel)
}
// 4
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = bounds
titleLabel.frame = CGRect(
x: 0,
y: Constants.statusBarHeight,
width: frame.width,
height: frame.height - Constants.statusBarHeight)
}
这段代码做了些什么:
- 添加一些基本的初始化代码,设置了 resizing mask,clipping mode ,然后调用 configureView 方法配置 view。MDCAppBar 及其同伴不支持自动布局,因此在教程的这一节,只能自己计算 frame。
- 这个 view 只能通过代码来使用,因此要防止通过 Xib 或故事板来加载它。
- 配置视图,设置背景色为 .darkGray。当视图收起时,背景图片变成透明,只留下深灰色作为导航栏的颜色。同时将背景图片和 label 添加到 subview。
- 这段布局代码做了两件事。首先,确保背景图片填充 header 的整个 frame。然后,让 label 也填充 header view 的 frame,只不过需要考虑状态栏的高度,让 label 在状态栏下边缘和 header 下边缘的区域中垂直居中。
现在,header view 和它的 subview 准备好了,是时候配置 App Bar并将你的 header view 作为它的内容。
打开 ArticlesViewController.swift,添加导入 Material 组件的 import 语句。
import MaterialComponents
然后添加下列属性:
let appBar = MDCAppBar()
let heroHeaderView = HeroHeaderView()
一个属性用于 App Bar(MDCAppBar 实例),一个属性用于 HeaderView。
然后,在 ArticlesViewController 扩展的 // MARK: UI Configuration 一句处添加这个方法:
func configureAppBar() {
// 1
self.addChildViewController(appBar.headerViewController)
// 2
appBar.navigationBar.backgroundColor = .clear
appBar.navigationBar.title = nil
// 3
let headerView = appBar.headerViewController.headerView
headerView.backgroundColor = .clear
headerView.maximumHeight = HeroHeaderView.Constants.maxHeight
headerView.minimumHeight = HeroHeaderView.Constants.minHeight
// 4
heroHeaderView.frame = headerView.bounds
headerView.insertSubview(heroHeaderView, at: 0)
// 5
headerView.trackingScrollView = self.collectionView
// 6
appBar.addSubviewsToParent()
}
代码有点多,可以分解成以下几个步骤:
- 首先,添加 app bar 的 header view controller 作为 ArticalesViewController 的子控制器。这是必须的,这样 header view controller 就可以接收标准 UIViewController 事件。
- 将 app bar 的背景色设置为 clear,因为你要用 hero header view 子类来作为这个颜色。还要设置 titleView 属性为 nil,因为 hero header view 也提供了一个自定义的 title。
- 设置 app bar 的 flexible header view,首先设置它的背景色为 clear,同样因为你准备用 hero header view 子类作为背景色。然后设置最大高度和最小高度为你在 HeroHeaderView 中指定的值。当 collection view 位于滚动位置 0 时(即最顶部),app bar 将是最大高度。当你滚动内容,app bar 会收缩到最小高度,并停留在那里,知道 collection view 又滚回顶部。
- 这里设置 hero header view 的初始 frame 为 header view 的大小,然后将它插入到 header view 的最下一层 subview。这能快速地将 hero head view 作为 app bar 的 flexible header view 的主要内容。
- 然后,设置 header view 的 trackingScrollView 为 Collection view。flexible header 需要知道用哪个 UIScrollView 子类来跟踪滚动事件,因此才能在用户滚动时调整大小、位置和 subview。
- 最后,调用 app bar 的 addSubviewsToParent,以便将它的几个 view 添加到 view controller 的 view。
现在,在 viewDidLoad() 方法的 super.viewDidLoad() 一句后添加:
override func viewDidLoad() {
super.viewDidLoad()
configureAppBar()
configureCollectionView()
refreshContent()
}
Build & run,你会看到:
很好,header 出现了!但有几个问题。
Flexible header view
首先,标题中的 logo 文字太小了,看起来好丑。滚一下 collection view,你会发现 flexible header view 也没有弹性。
这两个问题都说明仍然需要一些配置让 app bar 响应 collection view 的滚动事件。
仅仅是简单地设置 flexible header 的 trackingScrollView 是不够的。还需要明确地通过 UIScrollViewDelegate 方法通知它滚动事件。
在 ArticlesViewController 的 UI Configuration 扩展的 configureAppBar() 之后中添加这几个方法:
// MARK: UIScrollViewDelegate
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
let headerView = appBar.headerViewController.headerView
if scrollView == headerView.trackingScrollView {
headerView.trackingScrollDidScroll()
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let headerView = appBar.headerViewController.headerView
if scrollView == headerView.trackingScrollView {
headerView.trackingScrollDidEndDecelerating()
}
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let headerView = appBar.headerViewController.headerView
if scrollView == headerView.trackingScrollView {
headerView.trackingScrollDidEndDraggingWillDecelerate(decelerate)
}
}
override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let headerView = appBar.headerViewController.headerView
if scrollView == headerView.trackingScrollView {
headerView.trackingScrollWillEndDragging(withVelocity: velocity,
targetContentOffset: targetContentOffset)
}
}
在每个方法中,都需要检查 scrollView 是不是你想要的那个(即 header view 的 trackingScrollView),如果是,传递这个事件。
Build & run,你会发现 header 的高度变得有弹性了。
添加其他特效
现在 flexible header 正确地和 collection view 的滚动绑定了,为了创建某些漂亮的效果,你需要让 HeroHeaderView 能够响应 header 的滚动位置的改变。
打开 HeroHeaderView.swift 添加下列方法:
func update(withScrollPhasePercentage scrollPhasePercentage: CGFloat) {
// 1
let imageAlpha = min(scrollPhasePercentage.scaled(from: 0...0.8, to: 0...1), 1.0)
imageView.alpha = imageAlpha
// 2
let fontSize = scrollPhasePercentage.scaled(from: 0...1, to: 22.0...60.0)
let font = UIFont(name: "CourierNewPS-BoldMT", size: fontSize)
titleLabel.font = font
}
这个方法很短,但很重要。
首先,这个方法只有一个参数就是 scrollPhase 值。所谓 scroll phase 值是一个 0-1.0 之间的数字,0 表示这个 flexible header 处于最小高度,1 表示 Header 处于最大高度。
通过开始项目中的一个 scaled 实用扩展,scroll phase 能够被映射成两个 header 组件的对应值:
- 将 0…0.8 映射为 0…1,当 header 完全收起时,背景的 alpha 值是 0,当它展开时为 1。防止用户一开始滚动内容,图片就淡出。
- 将标题 logo 的字体大小映射为 22.0…60.0。也就是说当 header 完全展开时,标题 Logo 大小为 60,当它收起时会变小。
要调用这个方法,打开 ArticlesViewController.swift 添加这个扩展:
// MARK: MDCFlexibleHeaderViewLayoutDelegate
extension ArticlesViewController: MDCFlexibleHeaderViewLayoutDelegate {
public func flexibleHeaderViewController(_ flexibleHeaderViewController: MDCFlexibleHeaderViewController,
flexibleHeaderViewFrameDidChange flexibleHeaderView: MDCFlexibleHeaderView) {
heroHeaderView.update(withScrollPhasePercentage: flexibleHeaderView.scrollPhasePercentage)
}
}
将 header scroll phase 事件通过刚刚添加的方法传递给 hero header view。
最后但同样很重要的一点,在 configureAppBar() 中添加这一句,以便绑定 header 的布局委托:
appBar.headerViewController.layoutDelegate = self
Build & run,你会看到:
如果你进行滚动,header 会收起,背景图片淡出,标题 logo 变小。如果你在 collection view 位于 content offset 最上端的时候下拉,flexible header 会用自己的效果来拉伸内容。
接下来,你将添加一个材料设计风格的滚动的 tab bar,以便允许你选择不同的新闻源。
添加 Tab Bar
虽然从 CNN 阅读单一的新闻列表已经让这个 app 可以用了,但如果能选择不同的新闻源则更好。材料设计包含了这样一个组件,可以显示这样的列表:tab bar。
稍等,iOS 不是也有一个 tab bar 吗?
确实,但材料设计的 tab bar 既可以是一个底部风格的同时显示图标和标题按钮的 bar(就像 iOS tab bar 一样),也可以作为 flexible header 的一部分,以水平滚动的方式显示 title 的列表。
第二种模式更适合于一个列表,只有在运行时你才能确定它的数目,而且标题可以动态扩展,你无法为每个按钮提供一个唯一的图标。这真是太适合你进行新闻源的导航了。
打开 ArticlesViewController.swift 为 tab bar 添加一个属性声明:
let tabBar = MDCTabBar()
你要将 tab bar 当做 app bar 的“底部的 bar”,这样它会吸附在 flexible header 的底层,以便它总是可见的,无论 flexible header 是展开还是收起状态。在 configureAppBar() 下面添加这个方法:
func configureTabBar() {
// 1
tabBar.itemAppearance = .titles
// 2
tabBar.items = NewsSource.allValues.enumerated().map { index, source in
return UITabBarItem(title: source.title, image: nil, tag: index)
}
// 3
tabBar.selectedItem = tabBar.items[0]
// 4
tabBar.delegate = self
// 5
appBar.headerStackView.bottomBar = tabBar
}
看起来没什么难的:
- 首先,将它的外观设置为 .titles,这会让 tab bar 的 items 只显示 title,不显示 icon。
- 将所有的新闻源(用 NewsSource 枚举表示)映射成 UITabBarItem 实例。就像在一个 UITabBar 中定义一个 tab 一样。根据新闻源在列表中的索引来设置 tab bar item 上的 tab。当 tab bar 被点击时,你才能知道哪个新闻源被选中。
- 设置 selected item 为第一个。当 app 一启动,这将让第一个新闻源作为选中的新闻源。
- 简单地设置 tab bar 的委托为 self。你将在下一节实现这个委托。
- 最后,设置 header 的 bottom bar 为 tab bar,以便让它吸附在 flexible header 的底部。
tab bar 能够被配置好,但你必须调用这个方法。在 viewDidLoad() 方法的 configureAppBar() 之后调用这个方法:
configureTabBar()
这个 bar 已经配置好了,但仍然还没有完,我们还要实现委托方法。
添加一个新的扩展,实现这个方法:
// MARK: MDCTabBarDelegate
extension ArticlesViewController: MDCTabBarDelegate {
func tabBar(_ tabBar: MDCTabBar, didSelect item: UITabBarItem) {
refreshContent()
}
}
每当选择的 tab 发生改变时,刷新内容。这当然不对,因为我们还需要修改 refreshContent() 方法,让它根据选中的 tab 而变。
修改 refreshContent() 为:
func refreshContent() {
guard inProgressTask == nil else {
inProgressTask?.cancel()
inProgressTask = nil
return
}
guard let selectedItem = tabBar.selectedItem else {
return
}
let source = NewsSource.allValues[selectedItem.tag]
inProgressTask = apiClient.articles(forSource: source) { [weak self] (articles, error) in
self?.inProgressTask = nil
if let articles = articles {
self?.articles = articles
self?.collectionView?.reloadData()
} else {
self?.showError()
}
}
}
上述代码和开始项目原来的方法很像——除了一个地方不同。原来是将新闻源硬编码为 .cnn,现在通过 tabBar.selectedItem 获得选中的 tab bar item。然后从 tag 中找出对应的新闻源枚举值——还记得吗,前面已经将 tag 的值设置为新闻源的索引了。最后,将新闻源传递给 API 客户端的方法来抓取文章。
差不多要完成了!在完成 tab bar 的设置之前,还有一件事情要做。
在配置 app bar 时,你设置了绝对的最大最小高度。如果不做修改,当 app bar 处于收起状态时,你无法为 tab bar 提供额外的空间。Build & run,当你向下拉内容时,你会看到:
如果 app bar 能为 title 和 tab bar 都提供空间,看起来会更漂亮。
打开 HeroHeaderView.swift 将 Constants 修改成:
struct Constants {
static let statusBarHeight: CGFloat = UIApplication.shared.statusBarFrame.height
static let tabBarHeight: CGFloat = 48.0
static let minHeight: CGFloat = 44 + statusBarHeight + tabBarHeight
static let maxHeight: CGFloat = 400.0
}
添加了一个新的常量 tabBarHeight,然后在 minHeight 中加上这个常量。当 app bar 处于收起状态时,这将为 title 和 tab bar 留出足够的空间。
最后,还有一个问题需要解决。因为你添加了一个新的组件到 flexible header 中,title 不再垂直居中了。你可以修改 HeroHeaderView.swift 中的 layoutSubviews() 方法为:
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = bounds
titleLabel.frame = CGRect(
x: 0,
y: Constants.statusBarHeight,
width: frame.width,
height: frame.height - Constants.statusBarHeight - Constants.tabBarHeight)
}
唯一不同的地方是,在计算 title label 的高度时,减去了 Constants.tabBarHeight。
这就让 title label 在状态栏和 tab bar 之间垂直居中了。这看起来更漂亮,也避免了那些烦人的 UX 设计师在你睡着的时候从你的窗子外边扔进一块砖头进来。
Build & run,你可以选择新闻源了,无论 header 展开还是收起。
到现在为止,你为 header 和导航栏做了大量工作,是时候对内容进行一个显眼的材料设计了。
添加文章卡片
材料设计的一个核心信条之一是,把材料用作一种隐喻。卡片是就是这种隐喻的一种很好的实现,用于组织内容,表示层次或结构,表示交互,往往使用一定的突出和偏移。
在你的 app 中的单个新闻条目是非常呆板的。不过你会将它变成卡片式并带有水波纹点击效果。
打开 ArticleCell.swift 添加一条 import 语句导入材料组件:
import MaterialComponents
添加下列代码在 ArticleCell 底部,以便为 cell 添加一个阴影:
override class var layerClass: AnyClass {
return MDCShadowLayer.self
}
var shadowLayer: MDCShadowLayer? {
return self.layer as? MDCShadowLayer
}
我们覆盖了 UIView 的 layerClass 属性,以便将 view 后面的 layer 强制转换成 MDCShadowLayer 类型。
这个 layer 允许你设置一个阴影突出值,并绘制一条漂亮的阴影。你然后暴露一个方便的属性 shadowLayer,以便你轻松访问到阴影图层,以便设置。
现在阴影层已经就绪,添加下列代码到 awakeFromNib():
// 1
shadowLayer?.elevation = MDCShadowElevationCardResting
// 2
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
// 3
clipsToBounds = false
imageView.clipsToBounds = true
代码按照注释中的编号分成这几个步骤:
- 首先,将 shadowLayer 的 elevation 设置为 MDCShadowElevationCardResting。这是卡片在精止状态的默认突出高度。其它几种 elevation 分别对应了不同组件类型和交互类型。
- 然后,设置 layer 的 rasterization 模式以便改善滚动性能。
- 最后,将 cell 的 clipsToBounds 设置为 false,而 imageView 的 clipsToBounds 设置为 true。因为你使用了 .scaleAspectFill 模式,这会让图片内容受视图约束。
Build & run。你会看到每个内容周边都添加了阴影,明显是一个卡片式的样式。
app 现在看起来更加像材料设计样式了。这些卡片仿佛在呼喊“快来点我吧”,但是,当你点击它们时却没有显示任何点击效果就直接跳到文章详情页了。
点击时的波纹效果
材料设计用一种统一的方法表示交互,你可以使用一个“ink” 组件,当点击发生时,它会显示一种微妙的水波纹效果。
让我们给卡片们泼点墨水吧!在 MDCInkTouchController 添加一个 ArticleCell 的属性:
var inkTouchController: MDCInkTouchController?
Ink touch controller 中提供了一个内置的 ink view 并负责出来交互。只需要初始化这个 ink touch controller 并将它添加到视图中就可以了。
在 awakFromNib() 中加入:
inkTouchController = MDCInkTouchController(view: self)
inkTouchController?.addInkView()
Ink touch controller 有一个 view 的弱引用,因此不需要担心循环持有的问题。
Build & run,然后点击卡片看看效果。
就是这样了!你已经拥有了一个功能齐全的、可投入运营的材料设计的 app。
接下来做什么?
你可以从这里下载已经完成的项目。
材料设计规范的内容十分广泛,这个 iOS 库包含了许多组件,在本教程中也无法一一涉及。如果你喜欢本教程中某些内容,也建议你去读一读。
另外,你可以在这里查看所有 iOS 材料设计组件的完整列表。这里有非常完整的文档,如果你想集成更多材料设计的特性到你的新 iOS app 中,这是一个让你开始的好地方。
如果你对本教程有什么建议和问题,请到论坛留言!