Google iOS 材料设计: 入门

原文: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)
}

这段代码做了些什么:

  1. 添加一些基本的初始化代码,设置了 resizing mask,clipping mode ,然后调用 configureView 方法配置 view。MDCAppBar 及其同伴不支持自动布局,因此在教程的这一节,只能自己计算 frame。
  2. 这个 view 只能通过代码来使用,因此要防止通过 Xib 或故事板来加载它。
  3. 配置视图,设置背景色为 .darkGray。当视图收起时,背景图片变成透明,只留下深灰色作为导航栏的颜色。同时将背景图片和 label 添加到 subview。
  4. 这段布局代码做了两件事。首先,确保背景图片填充 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()
}

代码有点多,可以分解成以下几个步骤:

  1. 首先,添加 app bar 的 header view controller 作为 ArticalesViewController 的子控制器。这是必须的,这样 header view controller 就可以接收标准 UIViewController 事件。
  2. 将 app bar 的背景色设置为 clear,因为你要用 hero header view 子类来作为这个颜色。还要设置 titleView 属性为 nil,因为 hero header view 也提供了一个自定义的 title。
  3. 设置 app bar 的 flexible header view,首先设置它的背景色为 clear,同样因为你准备用 hero header view 子类作为背景色。然后设置最大高度和最小高度为你在 HeroHeaderView 中指定的值。当 collection view 位于滚动位置 0 时(即最顶部),app bar 将是最大高度。当你滚动内容,app bar 会收缩到最小高度,并停留在那里,知道 collection view 又滚回顶部。
  4. 这里设置 hero header view 的初始 frame 为 header view 的大小,然后将它插入到 header view 的最下一层 subview。这能快速地将 hero head view 作为 app bar 的 flexible header view 的主要内容。
  5. 然后,设置 header view 的 trackingScrollView 为 Collection view。flexible header 需要知道用哪个 UIScrollView 子类来跟踪滚动事件,因此才能在用户滚动时调整大小、位置和 subview。
  6. 最后,调用 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 组件的对应值:

  1. 将 0…0.8 映射为 0…1,当 header 完全收起时,背景的 alpha 值是 0,当它展开时为 1。防止用户一开始滚动内容,图片就淡出。
  2. 将标题 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
}

看起来没什么难的:

  1. 首先,将它的外观设置为 .titles,这会让 tab bar 的 items 只显示 title,不显示 icon。
  2. 将所有的新闻源(用 NewsSource 枚举表示)映射成 UITabBarItem 实例。就像在一个 UITabBar 中定义一个 tab 一样。根据新闻源在列表中的索引来设置 tab bar item 上的 tab。当 tab bar 被点击时,你才能知道哪个新闻源被选中。
  3. 设置 selected item 为第一个。当 app 一启动,这将让第一个新闻源作为选中的新闻源。
  4. 简单地设置 tab bar 的委托为 self。你将在下一节实现这个委托。
  5. 最后,设置 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

代码按照注释中的编号分成这几个步骤:

  1. 首先,将 shadowLayer 的 elevation 设置为 MDCShadowElevationCardResting。这是卡片在精止状态的默认突出高度。其它几种 elevation 分别对应了不同组件类型和交互类型。
  2. 然后,设置 layer 的 rasterization 模式以便改善滚动性能。
  3. 最后,将 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 中,这是一个让你开始的好地方。

如果你对本教程有什么建议和问题,请到论坛留言!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值