iOS 中的 UI 自适应
早些年开发 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 找他,他会十分欢迎。
概述(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 都能相应得到更新。那么我们该怎么做呢?我建议大家采取下列五个步骤:
- 搭建基础布局
- 这一步是用来“让我们搭建需要在屏幕上显示的内容”,以及搭建我们大多数时候想展示的布局的。
- 选择要重构的屏幕分类
- 卸载无关的约束
- 我们在这里将谈论自动布局 (Auto Layout)。这些约束将约定了不同视图的尺寸和位置,你可以通过“卸载”操作将不需要的约束移除。当我们选择一个特定的屏幕分类时,我可能就想要把一些要改变的约束移走。
- 为特定的屏幕分类添加新的约束
- 这一步将确保我们在新的屏幕尺寸中能够得到实际想要的布局。
- 对其他需要的屏幕分类重复上述步骤
- 最重要的事情就是不要为 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)
- 了解自动布局
- 这个技术的学习曲线很高,但是它不是不可能学会的,并且值得去学。学起来并不如你刚看到那样困难。
- 使用自适应布局来构建大致框架
- 只使用这些自适应工具并不能让你完成所有的布局。它们只是让你的布局看上去有个大概,然后你需要深入到代码当中,然后实现需要细粒度的玩意儿,比如说视图变换以及尺寸变换之类的东西。
- 从基本布局开始,然后执行重载
- 永远,永远不要在故事板中这样做:“我想要一个 iPad 版本,那么我将从常规/常规类型开始构建。现在,我要针对竖屏 iPhone 布局了,因此我到常规/紧凑类型中构建”。相反,你应该从一个通用的基础布局开始,然后才开始适配工作,好好想想针对这个特定分类你需要重构哪些东西。
- 堆栈视图让生活更简单
- 如果你可以使用 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>