iOS 中的 UI 自适应

jopen 9年前

早些年开发 iOS 的时候(那个时候 iOS 的前身被称为 “iPhone OS”),UI 设计是一个相当简单的事情——因为只需要为一种屏幕尺寸设计即可,这使得构建像素级完美设计 (pixel-perfect) 成为可能。而到了今天,屏幕尺寸愈来愈多,旧有的 UI 构建方法已经完全无法适应了。不过苹果介绍了几种不同的技术来解决屏幕尺寸碎片化的问题,最为突出的就是自适应布局 (Adaptive Layout) 的概念了。

在本次 GOTO Conference CPH 2015 讲演中,Sam Davies 将带我们深入了解自适应布局,通过展示几种形象的例子来讲解自适应布局的理念,同时还会带来使用界面构造器 (Interface Builder) 时的一些小技巧。他同时还讨论了一些关于处理多屏幕尺寸的最佳用例,从 web、Android 以及 iOS 中汲取灵感。

See the discussion on Hacker News .

Sign up to be notified of new videos — we won’t email you for any other reason, ever.

About the Speaker: Sam Davies

Sam 集开发者、作者和教练的身份为一体。白天,他会为 raywenderlich.com 录制视频,撰写教程,参加会议以及做一位正经人士。到了晚上,他更喜欢出去,通过他的长号以及魔鬼般的步伐给人们带来欢乐。你可以在 Github 上找到他,名字是 sammyd ,也可以通过他的个人网站 iwantmyreal.name 找他,他会十分欢迎。

@iwantmyrealname

</div>

概述(0:00)

我是 Sam,推ters 上的名字是 @iwantmyrealname ,现在我为 Razeware 公司工作,这家小公司对管理 raywenderlich.com 团队付出了极大的努力。现在让我们开始谈论自适应 UI 吧!

开始时都有什么?(1:16)

在我们为 “iPhone OS” 开发的黑暗年代,我们只有一种尺寸进行开发:那就是 3.5 寸的 iPhone,此外设计布局也是非常容易。实际上,虽然我们也需要处理横屏的情况,但是就算这样也只有两种尺寸。不过通常情况下,绝大多数应用是不允许让 iPhone 横屏的,必须要在竖屏中使用。但这个日子已经一去不复返了。

随后就是 iPad 的推出了,这块类似大板砖的东西掀起了一场革命。iPhone 和 iPad 之间的尺寸差异十分巨大。你可以选择为之搭建两个完全分离的应用,也可以在同一个应用中使用不同的布局进行开发,然而实际上不管怎样,所编译出来的都是两个分离的应用。

随后就是 4寸 iPhone 的推出了,也就是 iPhone 5 和 5s。4 寸和 3.5 寸拥有相同的宽度,只不过是在底部多出了那么一点点空间而已。刚好可以向其中塞一个广告,这也是大家经常做的。只不过是在底部加点东西而已,没什么大不了的。通常情况下,现在的 3.5寸手机都是基于 4寸手机而设计的,就算底部丢点什么东西也无所谓,又不会让应用无法使用。没有人觉得有必要担心这么做的问题,那些使用老款手机用户的体验已经被我们抛弃了。

去年推出了 iPhone 6,它参考了 Android 中常见的屏幕尺寸,也就是 4.7寸。然后就是 5.5寸的 iPhone 6 Plus 和现在的 6s Plus,参考了晚餐盘子的尺寸。

最后,就是即将推出的巨型 iPad Pro 了,我们现在又要处理另一种新的尺寸了。

如果算上横屏和竖屏的话,你会发现我们现在要处理 12种不同的屏幕尺寸

很久以前,我们所写的代码可能会像这样:

if UIDevice.currentDevice().userInterfaceIdiom == .Pad {   if UIDevice.currentDevice().orientation == .LandscapeLeft ||      UIDevice.currentDevice().orientation == .LandscapeRight {     doSomething()   } else {     doSomethingElse()   }  } else {   if UIDevice.currentDevice().orientation == .LandscapeLeft ||     UIDevice.currentDevice().orientation == .LandscapeRight {     yetAnotherAlternative()   } else {     theFourthWay()   }  }

你会在代码中查看当前用户的设备类型,然后根据该类型来写布局代码。还有,当处于横屏状态时,处理布局的代码可能就会发生一点特殊的变化。这个行为是不可持续的,你不能一直为这 12 种屏幕尺寸分别写不同的布局代码,这种做法完全行不通。

自适应布局介绍(4:53)

这就是为什么苹果要发布自适应布局的原因,这也是我们处理布局的首选方式之一。它将尺寸这个设计细节进行了抽象。我们无需再关心设备的类型或者设备的方向。相反,我们将这些观念用一种称为“屏幕分类 (size classes) ”的东西组织起来。

什么是屏幕分类呢?这不是在说屏幕有多少个像素点的问题,而是在说有多少空间的问题。我们有多少空间可供我们放东西进去呢?

我们将屏幕划分为两种不同的类型:紧凑的 (compact) 和常规的 (regular)。紧凑意味着空间不是很充足,我们会受到某种局限。常规意味着空间的余量是正常的,这就是这两种类型的涵义。

我们同时还会以两种不同的方向来讨论屏幕分类,就是横向和纵向。比如说横向紧凑、纵向常规之类的屏幕类型。这是一种分类的方式,描述特定视图的空间量。

设备上的屏幕分类(7:34)

那么这些概念是如何与真实设备映射的呢?

横向(Horizontal) 纵向(Vertical)
iPad 竖屏 常规 常规
iPad 横屏 常规 常规
iPhone 竖屏 紧凑 常规
iPhone 横屏 紧凑 紧凑
iPhone 6(s) Plus 横屏 常规 紧凑

当你在使用 iPad 或者 iPad Pro 的时候,两个方向都是常规类型。横向和纵向都存有大量的空间,一般来说在 iPad 上你随时都可以将足够的内容放到上面。

然而,当你看到竖屏的 iPhone 时,会发现它的宽度变为了紧凑型。对于 iPhone 来说,它的横向层面并没有太多的空间。接着当你将 iPhone 横置过来时,纵向方向上就没有太多空间了,因此纵向变为了紧凑。如果你在竖屏的 iPhone 上查看内容的时候,纵向方向有着充足的空间,因为你可以上下滚动,但是当你将其横置过来的时候,一般来说你是无法左右滚动的。没有人愿意读完某个东西后,滚动到另一侧,然后如果要读取开头的话,还得将所有东西回滚回去。因此,我们可以将其认为是紧凑的。

在 iOS 9 中,随着多任务 (multi-tasking) 的推出,这个概念变得愈发重要。例如,无论其方向如何,iPad 通常是常规/常规尺寸的。然而,当你在 iOS 9 中使用新推出的多任务时,你可以在右侧进行滑动从而显示出其他的应用。如果你使用新的 iPad 的话,你可以将其推得更远,让两个应用分别对半占据屏幕。这时候,实际上它是将其视为运行着两个相邻 iPhone 应用的 iPad。即使这个应用是专门针对 iPad 推出的,但是在这个情形下它会使用和 iPhoen 相同的屏幕分类配置,也就是横向紧凑的纵向常规的屏幕配置。

随后,在 iPad Pro 中,我觉得你可以拥有两个相邻的常规/常规应用。这时候,我们已经将设备尺寸从特定设备中抽象出来了,我们现在可以在同一个设备上运行不同的尺寸设计。

适配自适应布局(11:16)

那么,怎样适配自适应布局才是合理的呢?我们的最终目标就是创建一个管理全局的故事板文件。这个故事板可以在 iPad、iPhone 以及所有不同尺寸的 iOS 设备上运行。我们不再使用 4 个不同的故事板来构建布局,我们同时也可以不再 UI 更新时一个个地对这几个故事板进行更改。对这个管理全局的故事板进行更新,就意味着所有设备上的 UI 都能相应得到更新。那么我们该怎么做呢?我建议大家采取下列五个步骤:

  1. 搭建基础布局
    • 这一步是用来“让我们搭建需要在屏幕上显示的内容”,以及搭建我们大多数时候想展示的布局的。
  2. 选择要重构的屏幕分类
  3. 卸载无关的约束
    • 我们在这里将谈论自动布局 (Auto Layout)。这些约束将约定了不同视图的尺寸和位置,你可以通过“卸载”操作将不需要的约束移除。当我们选择一个特定的屏幕分类时,我可能就想要把一些要改变的约束移走。
  4. 为特定的屏幕分类添加新的约束
    • 这一步将确保我们在新的屏幕尺寸中能够得到实际想要的布局。
  5. 对其他需要的屏幕分类重复上述步骤
    • 最重要的事情就是不要为 iPhone 纵向、横向布局分别创建不同的布局,然后在前往 iPad 的时候又重头开始。这会导致你对真实布局产生混乱和困惑。最好的方法就是以基础布局开始,然后根据不同的设备对之进行些许改变。

示例(13:22)

我在 GOTO Copenhagen 2015 上对这个方法写了一个示例,你可以在文章上方看到。我解释了如何从一个简单的基础布局,通过安装新的约束从而给不同的设备添加布局的。

什么是自适应的?(25:34)

什么类型的东西是可以自适应的呢?首先第一个是约束。你可以取一个约束,然后决定是否在特定的屏幕分类中显示它。这样,你就可以以多种不同的方式重新排列和组织布局了,这一点非常赞。但是这不仅仅是自适应布局的完全能力。还有其他东西也可以完成自适应。

您同样也可以改变某个约束的约束值 (constant)。如果有一个约束表示“这两个视图之间的间隔是 10 点”,那么我们可以让其变成:“如果有充足的空间,那么这个间隔可以是 100 点”。我无需删除并添加新的约束。奇怪的是,虽然你可以改变约束的倍数 (multiplier),但是为了实现这个效果,实际上你必须卸载这个约束创建一个新的才能实现这个效果。

你同样也可以改变字体。如果对于 iPhone 和 iPad 应用来说,我想改变字体大小以让 iPad 上的字体更大一些。我可以轻松完成这个操作。

最后就是视图的安装,这同样也是非常重要的。如果你想在 iPad 上重用 iPhone 上的布局的话,你可能不想改变字体大小以及间隔距离。你可能想要创建一个新的视图在 iPad 上显示,这个操作同样也是非常简单的。

屏幕分类以及字体改变示例(27:23)

这里有一个关于如何改变屏幕分类和字体大小的示例,单击此处来查看上面的视频!

与代码做抗争(31:12)

这在代码中该如何实现的呢?通过界面构造器非常轻松愉快,但是如果想要在代码中实现此功能的话,很可能就要面临着一场艰难的“战争”了。

public class UITraitCollection : NSObject, NSCopying, NSSecureCoding, NSCoding {    ...          public var userInterfaceIdiom: UIUserInterfaceIdiom { get }     public var displayScale: CGFloat { get }     public var horizontalSizeClass: UIUserInterfaceSizeClass { get }    public var verticalSizeClass: UIUserInterfaceSizeClass { get }      @available(iOS 9.0, *)    public var forceTouchCapability: UIForceTouchCapability { get }  }

所有我们所需要的东西都存在这个名为 UITraitCollection 的类中,它是去年被引入的。这是我们用来寻找设备不同之处的地方,包括用户界面风格(是 iPhone 风格还是 iPad 风格呢?)。你可以获取展示比例 (display scale),有可能是一倍、两倍甚至三倍,这取决于每个点所代表的像素值是多少。通过 traitCollection 实例,我们还可以找到当前的屏幕分类是什么。最后,如果在 iPhone 6s 或者 6s Plus 的设备中,你还可以找到是否可以使用 3D Touch 功能,因此就可以根据按压屏幕的力度做出相应的操作了。

你可以通过使用协议 UITraitEnvironment 来获取 traitCollection 实例。

public protocol UITraitEnvironment : NSObjectProtocol {    public var traitCollection: UITraitCollection { get }      public func traitCollectionDidChange(previousTraitCollection: UITraitCollection?)  }

UIScreen 、 UIWindow 、 UIPresentationController 、 UIViewController 以及 UIView 都实现了次协议,因此这意味着你处于这些类当中的时候,你就可以找到当前的 traitCollection ,因此你就可以知晓当前的屏幕分类。只需要通过访问 traitCollection ,你所想知道的一切都会从中得到。

你同样也会注意到这个 traitCollectionDidChange 函数。当 traitCollection 发生变化时此函数就会被调用,但是何时会发生呢?如果你竖直放立一台 iPhone 然后将其旋转,接着每个视图控制器、每个视图、每个展示控制器 (presentation controller)、每个屏幕以及每个窗口的都会调用此 traitCollectionDidChange 函数,因为设备发生了旋转。您可以得到,设备从高度常规、宽度紧缩变成了高度紧缩、宽度紧缩(在 iPhone Plus 上是宽度常规)。当 traitCollection 发生改变时,你可以使用这些信息来处理旋转或者处理任何你想做的事情。

重载屏幕分类(33:44)

你可以对屏幕分类进行重载,但是你为什么要这么做呢?

extension UIViewController {    public func setOverrideTraitCollection(collection: UITraitCollection?,      forChildViewController childViewController: UIViewController)     public func overrideTraitCollectionForChildViewController(      childViewController: UIViewController) -> UITraitCollection?  }

你可以构建一个对给定屏幕分类有着特殊布局的视图控制器,接着你会想到:“我现在在 iPad 上,但是我已经构建了一个容器视图控制器,然后将其他视图控制器放到其当中去。我在宽度紧缩下定义该布局,因为这个视图控制器太小了,因此我想要使用一个容器视图控制器来对它进行管理”。

在这个情况下,你可以对这个特定的子视图控制器使用 traitCollection 。我虽然位于 iPad 的巨大画布当中,但是我的其中一个子视图控制器非常小(比如说宽度紧缩)。

你可以使用上面的这些代码,并且用起来很容易:你可以自行构建一个 traitCollection ,然后重写里面你想要的属性,然后通过 UIViewController 中的这个方法将其传到子视图控制器当中。

UIContentContainer (34:46)

最后一个我想要进行介绍的协议是 UIContentContainer 。与在 traitCollection 中处理转换相比,这个方法稍微更细粒化 (fine-grained) 一些。

public protocol UIContentContainer : NSObjectProtocol {    ...          public func viewWillTransitionToSize(size: CGSize,      withTransitionCoordinator coordinator:      UIViewControllerTransitionCoordinator)          public func willTransitionToTraitCollection(      newCollection: UITraitCollection,      withTransitionCoordinator coordinator:      UIViewControllerTransitionCoordinator)  }

当你在用 traitCollectionDidChange 的过程中,突然,你收到消息说 traitCollection 发生了变化,需要重新构建布局。那么如何确保以一种好的方式来处理动画呢?因此,在 UIContentContainer 中你就可以在其中使用这些动画方法了。

UIViewController 和 UIPresentationController 都实现了 UIContentContainer 方法。它们都拥有 willTransitionToTraitCollection 方法。在变化发生前,你就会被告知 traitCollection 将要发生变化,因此你就可以得到一个 transitionCoordinator 实例。

transitionCoordinator 允许你这样做:“我想要执行一个动画,无论系统动画做了什么,这个动画都将执行”。非常简单,不是么?

这里的另外一个方法是 viewWillTransitionToSize 。问题是每个人都在问:“我的 iPad 无论什么方向都是常规/常规类型的?这简直就是扯淡!”。 viewWillTransitionToSize 可以解决这个问题。在 iOS 9 之前,这个方法只会在旋转时被调用,除非你自己搞了一些非常复杂的视图控制器容器变化。当你旋转 iPad 的时候,上面这个方法就会被调用。由于只有尺寸放生了变化,因此下面这个方法不会被调用。

被取消的旋转方法(36:41)

extension UIViewController {    @available(iOS, introduced=2.0, deprecated=8.0)    public var interfaceOrientation: UIInterfaceOrientation { get }          @available(iOS, introduced=2.0, deprecated=8.0,      message="Implement viewWillTransitionToSize:withTransitionCoordinator: instead")     public func willRotateToInterfaceOrientation(       toInterfaceOrientation: UIInterfaceOrientation, duration: NSTimeInterval)    @available(iOS, introduced=2.0, deprecated=8.0)    public func didRotateFromInterfaceOrientation(      fromInterfaceOrientation: UIInterfaceOrientation)           @available(iOS, introduced=3.0, deprecated=8.0,       message="Implement viewWillTransitionToSize:withTransitionCoordinator: instead")    public func willAnimateRotationToInterfaceOrientation(      toInterfaceOrientation: UIInterfaceOrientation, duration: NSTimeInterval)  }

在 iOS 8 中,这些处理旋转的好用老方法都不再赞成使用了。你不应该使用 willAnimateRotationToInterfaceOrientation 或者 didRotateFromInterfaceOrientation 方法了。那么现在应该怎么处理旋转呢?

使用 willTransitionToSize 来代替。这里有一个该方法的使用例子:

override func viewWillTransitionToSize(size: CGSize,    withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {        super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)    let image = imageForAspectRatio(size.width / size.height)          coordinator.animateAlongsideTransition({      context in       // 创建一个变化效果(transition),匹配上下文的持续时间(duration)      let transition = CATransition()      transition.duration = context.transitionDuration()            // 播放淡入淡出动画      transition.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)      transition.type = kCATransitionFade      self.backgroundImageView.layer.addAnimation(transition, forKey: "Fade")              // 设置新图片      self.backgroundImageView.image = image      }, completion: nil)  }

不要将旋转视作设备的移动。相反将其视为视图控制器尺寸的改变,因为从用户的角度来看,这才是真实发生的操作。这里的例子使用了一个变换协调器 (transition coordinator),调用了 animateAlongsideTransition 方法。这允许我们当旋转发生的时候,系统在这个动画中会获取到你的这个视图控制器,对其进行旋转,然后重新进行尺寸的跳转。

堆栈视图(37:57)

堆栈视图 (Stack Views) 是 iOS 9 新引入的东西。如果你此前从未使用过自动布局,现在是时候来学习它了,因为堆栈视图可以帮助你节约很大一部分的操作。试想我有一个包含三个子视图的空白视图。那么我应该怎么处理它们的约束呢?

首先我需要将顶部的这个视图与父视图顶部、左侧和右侧建立关联。我也要将底部视图与父视图底部建立关联。接着在它们之间加一些约束以将分隔它们开来。我同时还有让它们居中对齐,因此它们相互之间都是中心对齐的。最后,我想要指定它们的相关宽度,或许中间的这个视图将会使用固定的内容尺寸。这里有很多约束存在,尤其是搭建这么一个简单的视图关系。

通过堆栈视图,我可以减少至少 12 条约束。堆栈视图本身就拥有类似的属性,因此我告诉我所想面对的轴方向,然后设置间隔之类的东西。我让它们保持中央对齐。我必须使用这些约束,因为我必须将这个堆栈视图通过更宽的视图 (wider view) 来将其定位在某个地方。如果我想要让其尺寸更为精确的话,我甚至还可以多加一两个约束。

在 Xcode 当中,要学会使用这个箭头指下楼梯的这个按钮。这将创建一个堆栈视图。从中你就可以改变所有不同种类的东西了。

关于堆栈视图的一个有趣的事情就是它们的自适应性 (adaptivity) 非常高。这意味着我可以重载屏幕分类来添加诸如轴 (axis) 之类的东西。比如说,我可以将竖直对齐变更为水平对齐,只需要添加一个在堆栈视图上重载屏幕分类即可。我同样可以通过自适应性来轻易地改变对齐分布与间隔。自适应性非常强大,值得一看。

自适应性小技巧(41:17)

  1. 了解自动布局
    • 这个技术的学习曲线很高,但是它不是不可能学会的,并且值得去学。学起来并不如你刚看到那样困难。
  2. 使用自适应布局来构建大致框架
    • 只使用这些自适应工具并不能让你完成所有的布局。它们只是让你的布局看上去有个大概,然后你需要深入到代码当中,然后实现需要细粒度的玩意儿,比如说视图变换以及尺寸变换之类的东西。
  3. 从基本布局开始,然后执行重载
    • 永远,永远不要在故事板中这样做:“我想要一个 iPad 版本,那么我将从常规/常规类型开始构建。现在,我要针对竖屏 iPhone 布局了,因此我到常规/紧凑类型中构建”。相反,你应该从一个通用的基础布局开始,然后才开始适配工作,好好想想针对这个特定分类你需要重构哪些东西。
  4. 堆栈视图让生活更简单
    • 如果你可以使用 iOS 9 的话,你最好好好研究一下堆栈视图。如果你不能的话,网上仍然有很多有类似功效的开源库。他们可以让布局更为简单。如果你将堆栈视图嵌套在一起的话,这会让你的生活更加美好的。

现在是时候学习自适应了。正如我所说,这个时候你必须要构建 12 种不同的布局。不过借助自适应,你可以轻易地在各种不同的应用或者多个故事板文件之间穿梭,事情简单而又高效。快试试看自适应布局吧,看看你能做的以及所不能的。

最后,我再强调一下,我的 推ter 帐号是 @iwantmyrealname ,你可以在 我的 Github 上找到上述我所提及的示例代码。

问与答(43:01)

问:如何处理对齐呢?我们已经习惯了使用对齐来让每一个像素都达到完美。

Sam:这是使用自适应布局的一大挑战之一,这也是我认为在几年前的 Web 界这个想法就非常活跃了。我记得当我第一次做网页设计的时候,我花费了大量的时间让我的每一个像素在 Firefox 和 Internet Explorer 上都达到完美,到了后来还有 Chrome、Opera 等等之类的。你在那儿一直烦恼为什么 x 值不等于 y,最后我们似乎已经进入了一个“内容为重”的阶段。

但是我们完全不必要让这里、这里以及这里的像素都达到完美级别。这在 Web 应该是有效的。我们在应用上没有必要这么做,一切的一切都是为了简化开发。如果你告诉你的设计师,“好的,现在你可以做一个完美像素级别的设计了,现在就给我 12 种不同尺寸的设计吧,当然越多越好”。如果你告诉他们需要为一个 APP 设计 20 中不同完美像素级别的设计的话,相信我他们会开始考虑使用自适应的。

因此,这完全取决于在设计中你想要什么样的元素,比如说“当这个窗口变窄的时候,我们应该怎么重新组织布局呢?”因为这实际上是在 Web 上切实发生的,不是么?你将顶部的那个长长的菜单栏移除了,让其变成了汉堡包类似的下拉菜单,就像在 iPhone 上做的那样。这虽然可能不是一个正确的做法,但是唯一能解决的办法就是进行尝试。尝试走出像素完美的世界,然后重点关注于内容,我们可以用这些方式试着让页面看起来棒一些。

See the discussion on Hacker News .

Sign up to be notified of new videos — we won’t email you for any other reason, ever.

</div> </div>

来自: https://realm.io/cn/news/gotocph-sam-davies-adaptive-ui-ios/

</span></code></code></code></code></code></code></code></span>