原文:Yoga Tutorial: Using a Cross-Platform Layout Engine
作者:Christine Abernathy
译者:kmyhy
瑜伽(Yoga) 是一个跨平台的布局引擎,基于 Flexbox,它让布局变得更简单。可以用它替代 iOS 的自动布局和 web 的 CSS,也可以将它当成一种通用的布局系统使用。
Yoga 最初源自 Facebook 在 2014 年的一个开源的 css 布局开源库,在 2016 年经过修改,更名为 Yoga。Yoga 支持多个平台,包括 Java、C#、C 和 Swift。
框架开发者可以将 Yoga 集成到自己的布局系统中,就像 Facebook 在开源的 React Native 和 Litho 中所做的一样。当然,Yoga 也开放李一个框架,允许 iOS 开发者直接用于对视图进行布局。
在本教程中,你会用到主要的 Yoga 概念,并在构建 FlexAndChill app 中实践和扩展它们。
尽管使用的是 Yoga 布局引擎,但在阅读本文之前,熟悉自动布局仍然是有帮助的。你还需要了解如何在项目中使用 CocoaPods 来引入 Yoga。
弹性盒子,也叫做 CSS 弹性盒子,用于解决 web 中的复杂布局问题。这种布局的一个重要特性就是内容的布局在某个方向上填充,同时大小会“弹性地”适应某个空间。
弹性盒子由多个弹性容器组成,每个容器都有 1 个或多个 flex item:
弹性盒子定义了当 flex item 在弹性容器中如何布局。弹性容器之外的内容和 flex item 之内的内容照常渲染。
容器中的 flex item 在单一方向上进行布局(当然容器还可以再次被装在容器中)。这个方向就是 item 的主轴。而另外一个方向则是交叉轴。
弹性盒子允许你指定 item 如何放置以及在主轴和交叉轴上的空间分布。justify-content 用于指定 item 在容器主轴方向上的对齐。下图显示了当容器主轴方向为行的时候 item 的位置:
align-items 指定 item 在容器交叉轴方向上的对齐方式。下图显示了当容器的主轴为行时,也就是交叉轴为垂直方向时,item 的分布:
item 分别对齐于容器的上、中、下。
这些属性帮你对弹性盒子有一个大致的认识。你还会用到其它属性。有的属性控制了 item 相对于容器空间的拉伸和压缩。其它属性还包括设置边距、内白和大小。
要测试弹性盒子的一个绝佳地方是 jsFiddle,一个在线测试 JavaScript、HTML 和 CSS 的地方。
进入 starter JSFiddle,你应该看到 4 个面板:
在 3 个编辑器中的代码会在右下角的面板中看到结果。这个 starter 的例子会显示一个白色的框。
注意在 CSS 编辑器中定义的 yoga 类选择器。这个定义了 yoga 类的 CSS 实现。某些值和 Flexbox w3 规范不一致。例如,Yoga 指定 flex direction 默认为 column,items 从容器的开始位置摆放。任何使用了 class=”yoga” 的 HTML 元素都会进入 Yoga 模式。
看一下 HTML 代码:
<div class="yoga"
style="width: 400px; height: 100px; background-color: white; flex-direction:row;">
div>
这个 div 的基本样式是 yoga。另外 style 属性还设置了 size、background 属性并覆盖了默认的 flex direction 属性,因此 item 会排成行。
在 HTML 编辑器中,在关闭的 div 标签前添加如下代码:
<div class="yoga" style="background-color: #cc0000; width: 80px;">div>
在 div 容器上添加了一个 yoga 样式,且宽 80 的红色方块。
点击顶部菜单的 Run 按钮。你会看到如下结果:
在根 div 下新增子元素,就在红色方块的后面:
<div class="yoga" style="background-color: #0000cc; width: 80px;">div>
这会再添加一个 80 像素宽的蓝色方块。
点击 Run。输出结果会在红色方块右边在加上一个蓝色方块:
将蓝色方块的 div 代码修改成:
<div class="yoga" style="background-color: #0000cc; width: 80px; flex-grow: 1;">div>
添加了一个 flex-grow 属性,这样方块会拉伸并填充剩下的空间。
点击 Run,看看修改后结果如何:
将整个 HTML 代码修改成:
<div class="yoga"
style="width: 400px; height: 100px; background-color: white; flex-direction:row; padding: 10px;">
<div class="yoga" style="background-color: #cc0000; width: 80px; margin-right: 10px;">div>
<div class="yoga" style="background-color: #0000cc; width: 80px; flex-grow: 1; height: 25px; align-self: center;">div>
div>
这在子 item 上添加了一个 padding,在红色方块右边添加了一个右边距,为蓝色方块设置了高度,然后指定蓝色方块居中对齐于容器。
点 Run 查看效果如下:
你可以查看最终的jsFiddle。你可以试一下其它布局属性和值。
虽然 Yoga 是基于弹性盒子的,但二者仍有一些不同。
Yoga 没有百分之百的实现了 CSS 弹性盒子。它忽略了非布局属性,比如颜色。Yoga 还修改了一些弹性盒子属性以提供更好的 Right-to-Left 支持。最后,Yoga 增加了一个 AspectRatio 属性,用于解决一些常见的需求,比如像图片这样的元素布局。
虽然你很想停留在 www 的世界中再玩一会儿,但这是一篇 Swift 教程。别担心,Yoga API 会让你沐浴在弹性盒子的余晖当中。你可以利用你的弹性盒子知识在你的 Swift 项目中。
Yoga 是用 C 写的,主要是为了性能上的优化和易于于其它平台集成。为了开发 iOS app,你需要使用 YogaKit,它是 C 实现的一个封装。
回忆弹性盒子的 web 示例,布局通过样式属性来配置。通过 YogaKit,布局通过 YGLayout 对象来配置。YGLayout 包含了 flex direction 属性、justify content 属性、align items 属性和 padding 、margin 属性。
YogaKit 将 YGLayout 暴露作为 UIView 的一个分类。这个分类在 UIView 中增加了一个 configureLayout(block:) 方法。block 闭包带一个 YGLayout 参数,你可以用这些数据来配置 view 的布局属性。
通过将每个子 view 配置所需的 Yoga 属性来构建布局。然后,调用根 view 上的 YGLayout 的 applyLayout(preservingOrigin:) 方法。然后布局会被计算并应用到根 view 和 subview 上。
新建 Swift Single View Application 项目,名为 YogaTryout。
你将用代码来创建 UI,你不需要使用故事板。
打开 Info.plist 删除 Main storyboard file base name 属性。然后,设置 Launch screen interface file base name 为空字符串。最后删除 Main.storyboard 和 LaunchScreen.storyboard。
打开 AppDelegate.swift 在 application(_:didFinishLaunchingWithOptions:) 方法 return 之前添加:
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = ViewController()
window?.backgroundColor = .white
window?.makeKeyAndVisible()
Build & run。你会看到一个空白的屏幕。
关闭 Xcode 项目。
如果你没有安装 CocoaPods,请打开终端窗口,安装它:
sudo gem install cocoapods
在终端窗口中,进入 YogaTryout.xcodeproj 所在目录。新建文件 Podfile ,编辑其内容为:
platform :ios, '10.3'
use_frameworks!
target 'YogaTryout' do
pod 'YogaKit', '~> 1.5'
end
在终端窗口运行命令安装 YogaKit 依赖:
pod install
会有类似如下输出:
Analyzing dependencies
Downloading dependencies
Installing Yoga (1.5.0)
Installing YogaKit (1.5.0)
Generating Pods project
Integrating client project
[!] Please close any current Xcode sessions and use `YogaTryout.xcworkspace` for this project from now on.
Sending stats
Pod installation complete! There is 1 dependency from the Podfile and 2 total pods installed.
从现在开始,我们将用 YogaTryout.xcworkspace 来打开项目了。
打开 YogaTryout.xcworkspace,Build & run。你看到的仍然是一个空白屏幕。
打开 ViewController.swift 添加 import 语句:
import YogaKit
这就导入了 YogaKit 框架。
在 viewDidLoad() 方法最后添加:
// 1
let contentView = UIView()
contentView.backgroundColor = .lightGray
// 2
contentView.configureLayout { (layout) in
// 3
layout.isEnabled = true
// 4
layout.flexDirection = .row
layout.width = 320
layout.height = 80
layout.marginTop = 40
layout.marginLeft = 10
}
view.addSubview(contentView)
// 5
contentView.yoga.applyLayout(preservingOrigin: true)
这段代码做了这些事情:
在 iPhone 7p 模拟器上 Build & run。你会看到一个灰色方块:
你可能奇怪,为什么不用指定的 frame 实例化一个 UIView 并设置它的背景色呢?哥们,请耐心点。当你添加内容到这个容器之后,你就会看到魔法出现了。
在 viewDidLoad() 方法应用布局之前添加:
let child1 = UIView()
child1.backgroundColor = .red
child1.configureLayout{ (layout) in
layout.isEnabled = true
layout.width = 80
}
contentView.addSubview(child1)
这段代码添加一个 80 像素宽的红色方块到 contentView。
然后,在上述代码之后添加:
let child2 = UIView()
child2.backgroundColor = .blue
child2.configureLayout{ (layout) in
layout.isEnabled = true
layout.width = 80
layout.flexGrow = 1
}
contentView.addSubview(child2)
这次添加了一个蓝色方块到容器中,宽 80 但允许它拉伸并填充剩余空间。如果这看起来很眼熟,那是因为这和 jsFiddle 中的代码很像。
Build & run。你会看到:
然后,添加下列代码在 contentView 的布局配置闭包中:
layout.padding = 10
这会为所有子元素添加一个内白。
在 child1 的布局配置闭包中添加:
layout.marginRight = 10
这会为红色方块右边增加一个边距。
最后,在 child2 的配置布局闭包中添加:
layout.height = 20
layout.alignSelf = .center
这指定了蓝色方块的高度并让它在容器中居中对齐。
Build & run 你会看到:
如果你想将整个灰色方块水平居中呢?哈,你可以在 contentView 的父 view 即 self.view 上设置 Yoga。
在 viewDidLoad()方法的 super 之后添加:
view.configureLayout { (layout) in
layout.isEnabled = true
layout.width = YGValue(self.view.bounds.size.width)
layout.height = YGValue(self.view.bounds.size.height)
layout.alignItems = .center
}
这会开启根 view 的 Yoga 并根据 view 的 bounds 配置宽、高布局。alignItems 指定了子元素为垂直居中对齐。记住,alignItems 指定了容器的子元素在交叉轴上的对齐方式。容器默认的 flex direction 是 column,因此交叉轴就是水平方向。
将 contentView 的布局配置中的 layout.marginLeft 一句移除。不再需要这个值李,因为父容器已经让这个元素居中对齐了。
最后,将:
contentView.yoga.applyLayout(preservingOrigin: true)
替换为:
view.yoga.applyLayout(preservingOrigin: true)
这会计算布局并应用到 self.view 及其子 view,包括 contentView。
Build & run。注意灰色方块现在居中对齐了:
让灰色方块垂直居中对齐也是同样简单。在 self.view 的布局配置闭包中添加这句:
layout.justifyContent = .center
将 contentView 布局配置中的 layout.marginTop 一句删除。因为父容器已经让它垂直居中了。
Build & run。你会看到,灰色方块已经垂直和水平居中了:
将设备旋转为横屏模式。哇喔,它们不再居中对齐李:
幸运的是,有一种方法能够获得设备旋屏通知,能帮助我们解决这个问题。
在类末尾添加方法:
override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// 1
view.configureLayout{ (layout) in
layout.width = YGValue(size.width)
layout.height = YGValue(size.height)
}
// 2
view.yoga.applyLayout(preservingOrigin: true)
}
这段代码做了这些事情:
将设备旋转会竖屏。Build & run。旋转为横屏。灰色方块将正确居中对齐:
如果你想和自己的代码进行对比,你可以从这里下载最终的 tryout 项目。
很好,你可能心里面嘀咕,如果你用 IB 构建这个布局话时间不会超过 3 分钟,包括正确地处理旋屏:
当你的布局变得超乎想象的复杂,或者嵌套的 stack view 让你大发其火的时候,你会对 Yoga 刮目相看的。
另外,你可能很久就没有再用 IB 了,而是使用代码布局,比如:布局锚点、可视化格式语言。如果这都能用,那么 Yoga 也没什么不可以。记住,可视化格式语言不支持 Yoga 的宽高比。
Yoga 也很容易理解,一旦你理解了弹性盒子的话。在 iOS 中使用 Yoga 之前,你可以有很多资源快速测试弹性盒子布局。
你对制造白色、红色和蓝色方块的兴趣已经消耗殆尽了吧?是时候加快进度了。在接下来的这一节,你将用全新的 Yoga 技能创建下图的视图:
下载并打开开始项目。它已经添加李 YogaKit 依赖。其它主类包括:
Build & run。你会看到一个黑色窗口。
为了便于规划布局,我用线框图画出我们将要实现的布局:
图中的每个框说明如下:
在构建每一部分布局的时候,你都会新的 Yoga 属性以及如何对布局细部进行微调有更好的理解。
打开 ViewController.swift 在 viewDidLoad() 的加载 plist 一句后添加如下代码:
let show = shows[showSelectedIndex]
这句读取了要显示的剧集。
Yoga 中有一个 aspectRatio 属性,允许通过设置子元素的纵横比来布局视图。AspectRatio 即宽高比。
在 contentView 被添加到 subview 之后添加代码:
// 1
let episodeImageView = UIImageView(frame: .zero)
episodeImageView.backgroundColor = .gray
// 2
let image = UIImage(named: show.image)
episodeImageView.image = image
// 3
let imageWidth = image?.size.width ?? 1.0
let imageHeight = image?.size.height ?? 1.0
// 4
episodeImageView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexGrow = 1.0
layout.aspectRatio = imageWidth / imageHeight
}
contentView.addSubview(episodeImageView)
代码解释如下:
Build & run。你会看到图片在垂直方向上被拉伸李,但图片的纵横比保持不变:
之前你就看到过将 flexGrow 应用到容器中子元素的写法。比如在上一个示例中,我们曾经通过将蓝色方块的 flextGrow 设置为 1 来拉伸它。
如果对不止一个子元素设置 flexGrow 属性,则这些子元素首先按照它们所需要的空间来布局。每个子元素的 flexGrow 被用于剩余空间的分布。
在连续剧统计视图,我们打算将中间的部分占据的空间是另外两个空间的 2 倍。
在 episodeImageView 添加到 subviews 之后添加代码:
let summaryView = UIView(frame: .zero)
summaryView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexDirection = .row
layout.padding = self.padding
}
这里将子元素布局成一行,并添加一点内白。
继续添加代码:
let summaryPopularityLabel = UILabel(frame: .zero)
summaryPopularityLabel.text = String(repeating: "★", count: showPopularity)
summaryPopularityLabel.textColor = .red
summaryPopularityLabel.configureLayout { (layout) in
layout.isEnabled = true
layout.flexGrow = 1.0
}
summaryView.addSubview(summaryPopularityLabel)
contentView.addSubview(summaryView)
这里添加了一个人气标签,并将它的 flexGrou 设置为 1。
Build & run,效果如下:
在将 summaryView 添加到 subviews 之前添加代码:
let summaryInfoView = UIView(frame: .zero)
summaryInfoView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexGrow = 2.0
layout.flexDirection = .row
layout.justifyContent = .spaceBetween
}
这里创建了一个新的容器 view,用于 summary 标签的子元素。注意 flexGrow 属性被设成了 2。因此,summaryInfoView 会占据比 summaryPopularityLabel 多出 2 倍的剩余空间。
继续添加代码:
for text in [showYear, showRating, showLength] {
let summaryInfoLabel = UILabel(frame: .zero)
summaryInfoLabel.text = text
summaryInfoLabel.font = UIFont.systemFont(ofSize: 14.0)
summaryInfoLabel.textColor = .lightGray
summaryInfoLabel.configureLayout { (layout) in
layout.isEnabled = true
}
summaryInfoView.addSubview(summaryInfoLabel)
}
summaryView.addSubview(summaryInfoView)
这个循环遍历了剧集要显示的所有 summary 标签。每个标签都是 summaryInfoView 容器的子元素。容器的布局规定这些标签应当按照左、中、右的方式放置。
Build & run,这些标签会显示出来:
要布局右边的空间,你可以在 summaryView 中再加一子元素。添加代码:
let summaryInfoSpacerView =
UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 1))
summaryInfoSpacerView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexGrow = 1.0
}
summaryView.addSubview(summaryInfoSpacerView)
这创建了一个 flexGrow 为 1 的空白。summaryView 有 3 个子元素。第一个子元素和第三个子元素会占据剩余容器空间的 25%,第二个子元素会占据剩余空间的 50%。
Build & run,效果如下:
继续构建布局,以学习更多关于空间和位置的例子。
在 summaryView 代码之后添加:
let titleView = UIView(frame: .zero)
titleView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexDirection = .row
layout.padding = self.padding
}
let titleEpisodeLabel =
showLabelFor(text: selectedShowSeriesLabel,
font: UIFont.boldSystemFont(ofSize: 16.0))
titleView.addSubview(titleEpisodeLabel)
let titleFullLabel = UILabel(frame: .zero)
titleFullLabel.text = show.title
titleFullLabel.font = UIFont.boldSystemFont(ofSize: 16.0)
titleFullLabel.textColor = .lightGray
titleFullLabel.configureLayout { (layout) in
layout.isEnabled = true
layout.marginLeft = 20.0
layout.marginBottom = 5.0
}
titleView.addSubview(titleFullLabel)
contentView.addSubview(titleView)
这段代码创建了 titleView 用作两个显示剧集名称的子元素的容器。
Build & run,你会看到标题:
继续添加代码:
let descriptionView = UIView(frame: .zero)
descriptionView.configureLayout { (layout) in
layout.isEnabled = true
layout.paddingHorizontal = self.paddingHorizontal
}
let descriptionLabel = UILabel(frame: .zero)
descriptionLabel.font = UIFont.systemFont(ofSize: 14.0)
descriptionLabel.numberOfLines = 3
descriptionLabel.textColor = .lightGray
descriptionLabel.text = show.detail
descriptionLabel.configureLayout { (layout) in
layout.isEnabled = true
layout.marginBottom = 5.0
}
descriptionView.addSubview(descriptionLabel)
这里创建了一个容器 view,指定了水平方向上的内白,添加了一个子元素用于显示聚集的剧情。
然后添加如下代码:
let castText = "Cast: \(showCast)";
let castLabel = showLabelFor(text: castText,
font: UIFont.boldSystemFont(ofSize: 14.0))
descriptionView.addSubview(castLabel)
let creatorText = "Creators: \(showCreators)"
let creatorLabel = showLabelFor(text: creatorText,
font: UIFont.boldSystemFont(ofSize: 14.0))
descriptionView.addSubview(creatorLabel)
contentView.addSubview(descriptionView)
这里添加了两个子元素到 descriptionView,用于显示更多信息。
Build & run,你会看到:
接下来是添加操作区域。
在 ViewController 扩展中,添加一个私有的助手方法:
func showActionViewFor(imageName: String, text: String) -> UIView {
let actionView = UIView(frame: .zero)
actionView.configureLayout { (layout) in
layout.isEnabled = true
layout.alignItems = .center
layout.marginRight = 20.0
}
let actionButton = UIButton(type: .custom)
actionButton.setImage(UIImage(named: imageName), for: .normal)
actionButton.configureLayout{ (layout) in
layout.isEnabled = true
layout.padding = 10.0
}
actionView.addSubview(actionButton)
let actionLabel = showLabelFor(text: text)
actionView.addSubview(actionLabel)
return actionView
}
这里创建了一个容器 view,包含一个 image 和一个 label,水平居中对齐。
然后,在 viewDidLoad() 方法的 descriptionView 的代码下边,添加:
let actionsView = UIView(frame: .zero)
actionsView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexDirection = .row
layout.padding = self.padding
}
let addActionView =
showActionViewFor(imageName: "add", text: "My List")
actionsView.addSubview(addActionView)
let shareActionView =
showActionViewFor(imageName: "share", text: "Share")
actionsView.addSubview(shareActionView)
contentView.addSubview(actionsView)
这里创建了一个容器 view,包含两个用 showActionViewFor(imageName:text) 方法生成的子元素。
Build & run。
接下来布局 tab 按钮。
在 ViewController 扩展中新建方法:
func showTabBarFor(text: String, selected: Bool) -> UIView {
// 1
let tabView = UIView(frame: .zero)
tabView.configureLayout { (layout) in
layout.isEnabled = true
layout.alignItems = .center
layout.marginRight = 20.0
}
// 2
let tabLabelFont = selected ?
UIFont.boldSystemFont(ofSize: 14.0) :
UIFont.systemFont(ofSize: 14.0)
let fontSize: CGSize = text.size(attributes: [NSFontAttributeName: tabLabelFont])
// 3
let tabSelectionView =
UIView(frame: CGRect(x: 0, y: 0, width: fontSize.width, height: 3))
if selected {
tabSelectionView.backgroundColor = .red
}
tabSelectionView.configureLayout { (layout) in
layout.isEnabled = true
layout.marginBottom = 5.0
}
tabView.addSubview(tabSelectionView)
// 4
let tabLabel = showLabelFor(text: text, font: tabLabelFont)
tabView.addSubview(tabLabel)
return tabView
}
代码解释如下:
在将 actionsView 添加到 contentView 的代码后面添加如下代码(在 viewDidLoad 方法中):
let tabsView = UIView(frame: .zero)
tabsView.configureLayout { (layout) in
layout.isEnabled = true
layout.flexDirection = .row
layout.padding = self.padding
}
let episodesTabView = showTabBarFor(text: "EPISODES", selected: true)
tabsView.addSubview(episodesTabView)
let moreTabView = showTabBarFor(text: "MORE LIKE THIS", selected: false)
tabsView.addSubview(moreTabView)
contentView.addSubview(tabsView)
这里创建李一个 tab 按钮的容器 view,并将 tab 按钮添加到容器。
Build & run,你会看到:
在本例中,点击 tab 按钮是没有作用的。如果你后面准备添加这个功能的话,大部分工作都已经就绪李。
基本上差不多李。你还需要添加一个 table view。
在 tabView 被添加到 contentView 之后添加如下代码:
let showsTableView = UITableView()
showsTableView.delegate = self
showsTableView.dataSource = self
showsTableView.backgroundColor = backgroundColor
showsTableView.register(ShowTableViewCell.self,
forCellReuseIdentifier: showCellIdentifier)
showsTableView.configureLayout{ (layout) in
layout.isEnabled = true
layout.flexGrow = 1.0
}
contentView.addSubview(showsTableView)
这里创建并布局了 table view。布局配置中,将 flexGrow 设为 1,允许 table view 拉伸并填充剩余空间。
Build & run,你会看到视图中的列表显示出来了:
恭喜你!如果你坚持到了这里,表明你已经是一个 Yoga 高手了。铺起你的瑜伽垫,穿上弹力裤,深呼吸。你可以从这里下载最终完成的项目。
要了解更多属性,比如 Right-to-Left,请阅读Yoga 文档。
弹性盒子规范是一个关于弹性盒子的极好的参考。弹性盒子学习参考是一个查找各种弹性盒子属性的绝佳的指南。
希望你喜欢本教程。如果有任何想法或问题,请在下面留言。