跳转至

第20章:令人愉悦的用户体验--Layout

随着功能的完成和你的应用程序运行得如此之好,现在是时候让用户界面看起来和感觉令人愉快了。遵循帕累托80/20原则,这最后20%的代码往往会花费80%的时间。但这是值得的,因为虽然确保应用程序的运行很重要,但除非你的应用程序看起来和感觉很好,否则没有人会愿意使用它。

启动程序

自上一章的挑战项目以来,该项目有了一些变化。这些是主要的变化:

  • 为了防止巨大的、单一的视图,经常进行重构是个好主意。CardDetailView越来越难读了,所以启动程序已经把模态视图移到它们自己的视图修改器CardModalViews中。
  • 资产目录中有更多令人愉悦的随机颜色可用于背景,以及你在最后这些章节中会用到的其他颜色。
  • ResizableView使用了一个视图比例因子,这样以后你就可以很容易地缩放卡片。默认的比例是1,所以开始时你不会注意到它。

  • CardsApp用提供的默认预览数据初始化应用程序数据,这样你就有了和章节一样的数据。当你想再次开始保存自己的卡片时,记得在CardsApp.swift中改成@StateObject var store = CardStore()

  • CardStore中修正了卡片的删除,这样删除的卡片会从Documents以及cards中删除所有图片文件。
  • CardDropsizeframe属性,你将在挑战赛中使用。

这是你到目前为止创建的应用程序的视图层次结构。

View Hierarchy

正如你所看到的,它是非常模块化的。例如,你可以改变卡片缩略图的样子,并把它直接插回去。你可以很容易地在工具栏上添加按钮,并添加一个相应的模态。

你实例化了一个单一的真理源--CardStore--并通过绑定将其传递给所有这些视图。

设计卡片列表

这个应用程序的设计者为光明和黑暗模式提出了这样的设计:

App Design

顶部的分段控制器将在网格列表视图和旋转木马之间交换显示。底部的按钮很宽。这是你要尝试复制的设计。

添加列表的背景颜色

➤ 在向项目添加任何东西之前,在模拟器中构建并运行应用程序,并选择Device ▸ Erase All所有内容和设置...

这将删除你到目前为止为该应用创建的所有数据。目前,你将使用该应用程序提供的默认数据。

➤ 打开CardsView.swift

➤ 给ZStack添加一个修改器:

.background(
  Color("background")
    .edgesIgnoringSafeArea(.all))

这将使用资产目录中一个名为background的颜色作为背景色。这被定义为浅灰色代表浅色外观,深灰色代表深色外观。通过使用edgesIgnoringSafeArea(_:),可以确保背景覆盖所有的屏幕。

➤ 预览视图。在此图像中,为了清晰起见,背景颜色是粉红色;你的背景颜色将是浅灰色。

Background Color not showing up

即使你忽略了所有的安全区域,背景色也不是显示在整个视图中,而是只显示在滚动视图的区域。这是因为ZStack只占用其子视图所需的空间。

Layout

本节中你将学习的技能:控制视图布局

现在是时候深入了解一下SwiftUI是如何处理视图布局的。大多数时候,SwiftUI的视图都是自己布局的,而且看起来很好,你根本不需要考虑布局问题。但是,当你想要精确的定位,或者一个视图的行为方式与你想象的不一样时,你可能会开始与系统对抗。一旦你理解了布局,并从逻辑上对待它,那么一切都会变得容易得多。

布局是从视图层次结构的顶端开始的。父视图告诉它的子视图,"我建议这个尺寸"。然后每个子视图在父视图的可用空间内占用尽可能多的空间,并告诉父视图"我只需要这个尺寸"。这个过程一直延续到视图层次结构中。然后,父视图将自己的大小调整为其子视图的大小。

➤ 创建一个名为LayoutView.swift的新SwiftUI视图文件,以试验各种布局。如果你的文件中仍有ContentView.swift,你可以用它来代替。

➤ 在 LayoutView_Previews中,为LayoutView添加一个新的修改器:

.previewLayout(.fixed(width: 500, height: 300))

这给预览提供了一个固定的尺寸,即500 x 300

➤ 在LayoutView中,为Text添加一个新的修改器:

.background(Color.red)

➤ 预览该视图。红色显示了Text视图在屏幕上占用的空间。

Text with red background

在这里的视图树层次中,有三个视图:

LayoutView  Text (modified)  Red

LayoutView有一个500x300点的固定尺寸。Text占用指定字体大小的字母所需的空间。Color有点不同。它是一个迟到的绑定标记,这意味着尺寸是在最后一刻分配的。

一个Color视图会填满其父辈的整个空间。

Laying out views

➤ 将LayoutView改为:

struct LayoutView: View {
  var body: some View {
    HStack {
      Text("Hello, World!")
        .background(Color.red)
      Text("Hello, World!")
        .padding()
        .background(Color.red)
    }
    .background(Color.gray)
  }
}

➤ 在这里,你创建了一个有两个Text视图的水平堆栈。第二个Text有填充。

Laying out views

现在的视图树是:

LayoutView ➤ HStack ➤ Text (modified) ➤ Red
                    ➤ Text (modified) ➤ Padding (modified) ➤ Red
                    ➤ Gray 

LayoutView仍然有500x300点的固定尺寸。HStack500x300点呈现给它的孩子。第一个Text返回它所需要的空间,但是第二个文本有一个padding修改器,所以返回它的空间加上padding。然后HStack只占用它的两个子视图所需的空间,加上HStack在两个子视图之间的默认填充。HStack的灰色背景颜色填补了HStack在两个Text视图下面所占的空间。

每次你添加一个修改器,你都会在视图层次中创建一个新层。但是不要担心这样做的效率--SwiftUI的视图是轻量级的,添加新的视图是非常快的。

框架修改器

在以前的代码中,你使用frame(width:height:alignment:)改变了视图的默认尺寸,给widthheight以绝对值。

当你想相对于父视图的尺寸来布局视图时,你可以使用frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)指定最小和最大的宽度和高度。

➤ 在.background(Color.gray)之前,添加这个:

.frame(maxWidth: .infinity)

HStack现在告诉它的父辈,它想要最大的可用宽度,所以HStack,用灰色的颜色,扩展到整个视图的宽度。

Maximum width

还记得你之前提出的背景颜色只占ScrollView的宽度的问题吗?指定一个maxWidthmaxHeightinfinity的框架,是填满整个可用背景的一种方法。

GeometryReader

本节将学习的技能: GeometryReader; 使用给定的视图尺寸来布局子视图

然而,当你需要知道父视图的尺寸,以便你能更精确地布局子视图时,还有一种灵活的视图可以占用整个可用空间,并以点为单位给出尺寸。GeometryReader是一个容器视图,可以返回它的首选尺寸。稍后,你将使用GeometryReader根据可用空间的宽度来确定卡片缩略图的大小。

➤ 在LayoutView中,将HStack嵌入到GeometryReader中,并给它一个黄色背景:

GeometryReader { proxy in
  HStack {
    ...
  }
  .frame(maxWidth: .infinity)
  .background(Color.gray)
}
.background(Color.yellow)

GeometryReader占用了父体的大小,在这里是整个500 x 300点的视图。它返回一个GeometryProxy类型的值,其中包括一个size属性,这样你就可以准确地找到视图的尺寸。然后你可以用这个尺寸来布置子视图。

GeometryReader

注意GeometryReader改变了对齐行为。HStack不再在其父视图中居中,现在它被对齐到其父视图的左上方。在本章后面,你会发现更多关于对齐的内容。

➤ 将HStack的修改器改为:

.frame(width: proxy.size.width  0.8)
.background(Color.gray)
.padding(
  .leading, (proxy.size.width - proxy.size.width  0.8) / 2)

frame(width:height:alignment)现在使用一个相对值,即可用区域宽度的五分之四。如果父视图变大,例如在设备旋转时,proxy.size将更新并刷新视图。视图将调整到新的父级尺寸的五分之四。

为了使HStack居中,你要使用几何代理宽度来计算前导填充。

GeometryProxy size

注意这些修饰语的顺序。如果你改变其中任何一个的顺序,你会得到一个不同的结果。在填充颜色之前,你必须设置视图的大小。如果你在填充灰色之前计算了padding,那么你会把文本视图放在中心位置,但不会把背景灰色放在中心位置。

设置卡片缩略图的大小

当在iPad上显示卡片缩略图列表时,你有比小型设备更多的空间,所以缩略图的尺寸应该更大。如果宽度大于500点的阈值,你就会显示一个更大的缩略图。测试设备尺寸的一种方法是使用紧凑或常规布局。另外,你可以使用GeometryReader获得视图的确切尺寸,这就是你在这里要使用的方法。

➤ 打开CardsListView.swift

➤ 在GeometryReader中嵌入ScrollView

GeometryReader { proxy in
  ScrollView(showsIndicators: false) {
    ...
  }
}

➤ 打开CardsView.swift并预览。

ScrollView in GeometryReader

注意使用GeometryReader的副作用。在CardsView.swift中,CardsListView的父级是ZStackGeometryReader占用了所有的可用空间并将其传回给ZStack。这意味着ZStack的浅灰色背景颜色现在充满了整个屏幕。

第二个副作用是ScrollView的中心对齐方式消失了。你将在不久后添加网格视图时解决这个问题。

➤ 再次打开CardsListView.swift

ScrollView中,你现在可以访问proxy.size,它给你提供了CardsListView的父节点的全部可用空间。

➤ 将CardThumbnailView(card: card)改为:

CardThumbnailView(card: card, size: proxy.size)

你现在把尺寸传给缩略图,它可以采取适当的行动。在修复CardThumbnailView之前,你会得到一个编译错误。

➤ 打开CardThumbnailView.swift

➤ 在卡片属性后面添加大小:

var size: CGSize = .zero

➤ 打开Settings.swift,用thumbnailSize代替:

static func thumbnailSize(size: CGSize) -> CGSize {
  let threshold: CGFloat = 500
  var scale: CGFloat = 0.12
  if size.width > threshold && size.height > threshold {
    scale = 0.2
  }
  return CGSize(
    width: Settings.cardSize.width  scale,
    height: Settings.cardSize.height  scale)
}

缩略图的尺寸将缩减到卡片最终尺寸的12%。当提供的尺寸的宽度或高度大于500点时,就会缩放到最终尺寸的20%

➤ 在CardThumbnailView.swift中,将 frame(width:height:)替换为:

.frame(
  width: Settings.thumbnailSize(size: size).width,
  height: Settings.thumbnailSize(size: size).height)

现在你知道了卡片列表的可用屏幕空间,就可以相应地计算出缩略图的框架。

➤ 在iPadiPhone模拟器上建立和运行,并比较两个缩略图的大小。

Thumbnail sizes on iPad and iPhone

iPad上的缩略图尺寸要比iPhone上的大。

添加一个懒惰的网格视图

本节中你将学习的技能:GeometryProxy的尺寸计算

你将添加一个LazyVGrid来显示多列的卡片,而不是显示一列滚动的卡片。这应该是自适应的,取决于设备的当前显示宽度。

➤ 打开CardsListView.swift,为CardsListView添加一个新方法:

func columns(size: CGSize) -> [GridItem] {
  [
    GridItem(.adaptive(
      minimum: Settings.thumbnailSize(size: size).width))
  ]
}

这将返回一个GridItem数组--在本例中,有一个元素--你可以用它来告诉LazyVGrid每一行的大小和位置。这个GridItem是自适应的,这意味着网格将在提供的最小尺寸下尽可能多地容纳项目。

➤ 在body中,将ForEach嵌入到LazyVGrid中:

GeometryReader { proxy in
  ScrollView(showsIndicators: false) {
    LazyVGrid(columns: columns(size: proxy.size), spacing: 30) {
      ForEach(store.cards) { card in
        ...
      }
    }
  }
}

现在你有一个灵活的网格,垂直间距为30点。

➤ 在各种模拟器上构建并运行该应用程序,并从纵向切换到横向,以观察列的变化。

Grids on iPad and iPhones

Note

如果你想直观地了解视图所占用的空间,可以尝试在各种视图中添加.background(Color.red)作为修改器。

创建新卡的按钮

现在你要在屏幕的脚下放置一个按钮来创建一个新的卡片。

➤ 打开CardsView.swift

➤ 在CardsView中,删除VStack及其内容,这样ZStack只包含SingleCardView及其条件:

ZStack {
  if !viewState.showAllCards {
    SingleCardView()
  }
}
.background...

➤ 在CardsView中,创建一个新的按钮属性:

var createButton: some View {
// 1
  Button(action: {
    viewState.selectedCard = store.addCard()
    viewState.showAllCards = false
  }) {
    Label("Create New", systemImage: "plus")
  }
  .font(.system(size: 16, weight: .bold))
// 2
  .frame(maxWidth: .infinity)
  .padding([.top, .bottom], 10)
// 3
  .background(Color("barColor"))
}

你并不总是要为视图创建新的结构。有时,如果它是一个简单的视图,而且你只使用一次,那么将视图作为属性或方法进行跟踪会更容易。

浏览一下这段代码:

  1. 使用Label格式创建一个简单的按钮,这样你可以指定一个系统图像。当点击时,你创建一个新的卡片并将其分配给viewState.selectedCard。你把viewState.showAllCards设置为false,这样SingleCardView就会显示。
  2. 按钮一直延伸到整个屏幕上,减去填充物。
  3. 背景颜色在资产目录中。你很快就会自定义按钮的文本颜色。

你将把按钮作为另一个层添加到CardsListView的上面。

➤ 在ZStack的顶部,添加这个代码:

CardsListView()
VStack {
  Spacer()
  createButton
}

VStackSpacer将把按钮放在屏幕的底部。

现在CardsView看起来像这样:

ZStack {
  CardsListView()
  VStack {
    Spacer()
    createButton
  }
  if !viewState.showAllCards ...
}

➤ 构建和运行(或使用实时预览)并测试您的新按钮。

Create button

这个按钮的代码有一个"疑点"。虽然按钮的框架一直延伸到整个屏幕,但只有文本是可以触摸的。

➤ 在createButton中,将frame(maxWidth: .infinity)Button的修改器移到Label的修改器:

Button(action: {
...
}) {
  Label("Create New", systemImage: "plus")
    .frame(maxWidth: .infinity)
}
...

➤ 建立并再次运行。这个按钮看起来是一样的,但是可以一路敲击。

勾勒卡片的轮廓

打开CardThumbnailView.swift

使用RoundedRectangle的一个替代方法是使用卡片的背景颜色作为视图。

➤ 将RoundedRectangle(cornerRadius:)foregroundColor(_:)改为:

card.backgroundColor
  .cornerRadius(10)

这将改变角的半径,以配合设计,但除此之外,产生的结果与以前相同。

➤ 在frame(width:height:alignment:)之后,向card.background添加一个修改器:

.shadow(
  color: Color("shadow-color"),
  radius: 3,
  x: 0.0,
  y: 0.0)

在这里,你用你指定的颜色和3的半径添加一个阴影。由于xy的位置都是零,阴影将是三个点,围绕着视图。

这是一个非常微妙的轮廓颜色,但如果你的设计师告诉你要添加它,请相信设计师。:]

➤ 暂时将card.backgroundColor改为:

Color(UIColor.systemBackground)

由于卡的颜色现在与屏幕的背景颜色相同,你就可以看到阴影了。

➤ 预览视图,在检查预览中的深色和浅色方案之间切换。

Outline Colors with temporary card color

➤ 将Color(UIColor.systemBackground)改回:

card.backgroundColor

这将恢复你的卡片的背景颜色。

Outline Colors

设计卡片详情界面

本节中你将学习的技能:重点颜色;缩放固定尺寸视图

自定义重点颜色

应用程序的重点颜色决定了应用程序控件上文本的默认颜色。你可以通过改变资产目录中的AccentColor颜色来为整个应用程序设置这个颜色,也可以用accentColor(_:)修改器来改变每个视图的强调色。默认的是蓝色,这对文本按钮来说,效果一点也不好:

The default accent color

➤ 打开Assets.xcassets并选择AccentColor

使用应用程序模板创建新项目时,会自动创建AccentColor

➤ 将任何外观的颜色改为黑色,暗色外观的颜色改为白色。

Change the accent color

这将改变整个应用程序中所有控件的默认重点颜色。

➤ 打开CardsView.swift并预览它。

现在,创建按钮的文本是黑色的,不会显示在黑条上。

Black text

➤ 在createButton中,在background(Color("barColor")后添加一个新的修改器:

.accentColor(.white)

由于按钮在浅色和深色的外观下都是深色的,所以将按钮的重点颜色设置为始终是白色。

➤ 实时预览浅色和深色两种颜色方案中的视图:

Accent color

在整个应用程序中,除了在特定的视图上指定accentColor(_:)外,文本在Assets.xcassets中采用了AccentColor

缩放卡片以适应设备

目前,无论你使用什么设备或方向,卡片都会占据屏幕的全部尺寸。当你创建了一个纵向的卡片,然后把设备转为横向时,这显然是不行的。

你要创建的卡片的固定尺寸是13002000。无论方向如何,整个卡片都将一次可见,你将使用几何读卡器代理尺寸来计算卡片视图的适当尺寸。

➤ 打开CardDetailView.swift

➤ 向CardDetailView添加这些新方法:

func calculateSize(_ size: CGSize) -> CGSize {
  var newSize = size
  let ratio =
    Settings.cardSize.width / Settings.cardSize.height

  if size.width < size.height {
    newSize.height = min(size.height, newSize.width / ratio)
    newSize.width = min(size.width, newSize.height  ratio)
  } else {
    newSize.width = min(size.width, newSize.height  ratio)
    newSize.height = min(size.height, newSize.width / ratio)
  }
  return newSize
}

func calculateScale(_ size: CGSize) -> CGFloat {
  let newSize = calculateSize(size)
  return newSize.width / Settings.cardSize.width
}

这些方法使用一个给定的尺寸,以正确的长宽比计算卡片视图的大小和比例。这个尺寸将来自于GeometryReaderGeometryProxy

➤ 在body中,将content嵌入到GeometryReader中:

var body: some View {
  GeometryReader { proxy in
    content
      .onChange(of: scenePhase) ...

现在可以使用几何读取器的代理尺寸来计算content的框架。

➤ 在cardModals(card:currentModal:)之后向content添加这些修改器:

// 1
.frame(
  width: calculateSize(proxy.size).width ,
  height: calculateSize(proxy.size).height)
// 2
.clipped()
// 3
.frame(maxWidth: .infinity, maxHeight: .infinity)

在这几个修饰语中,有很多的布局:

  1. 鉴于可用空间,计算卡片视图的大小。
  2. 背景颜色会溢出框架,所以要把它剪掉。
  3. 确保content占据所有可用的空间。这将使卡片视图在几何阅读器中居中。

➤ 在视图组中,打开ResizableView.swift

请注意,这个文件中的新变化调整了所有的偏移和尺寸,使其按viewScale的比例进行缩放。这个默认值是1,所以如果你不想的话,就不必指定一个视图比例。

➤ 再次打开CardDetailView.swift

➤ 在var content中,将resizableView(transform: bindingTransform(for: element))改为:

.resizableView(
  transform: bindingTransform(for: element),
  viewScale: calculateScale(size))

ResizableView转换每个元素的大小时,它现在使用用代理大小计算的比例。

不幸的是,你的应用程序无法编译,因为proxysizecontent不可用。

➤ 将var content: some View {改为:

func content(size: CGSize) -> some View {

这就把content改成了一个方法,而不是一个属性,这样就可以传递几何代理的尺寸。

➤ 在body中,将content改为:

content(size: proxy.size)

➤ 在各种设备和方向上构建和运行,并检查您新缩放的卡片视图。卡片保持纵向,并固定为按比例的1300×2000的大小。

Scaled card in portrait and landscape

不幸的是,现在你有了一个新的问题! 当你添加照片时,由于卡片被缩放,它太小了,无法管理。

➤ 打开Settings.swift,将defaultElementSize改为:

static let defaultElementSize =
  CGSize(width: 800, height: 800)

➤ 构建并运行,现在可以添加适合设备尺寸的元素。

The scaled card

Alignment

本节将学习的技能:堆栈对齐

布局中的最后一个主题,你将涉及的是对齐。再看一下前面的图片。目前,你的工具栏按钮中的图像大小不一,这使得按钮的文字错位。你的关注细节的基因应该因为这个而在心里哭泣。

VStack(alignment:spacing:)HStack(alignment:spacing:)有可选的对齐参数。

Stack Alignment

HStack来描述子视图在垂直方向的对齐方式,用VStack来描述视图的水平对齐方式。

➤ 打开CardBottomToolbar.swift并预览该视图。

Xcode

不要忘记你的键盘快捷键Shift-Command-O来快速打开一个文件的名字。要在项目导航器中查看当前文件,请按Shift-Command-J

Misaligned preview of the toolbar buttons

目前,在CardBottomToolbar中,你的工具条按钮是在一个居中对齐的HStack中。这意味着ToolbarButtonViewVStack组成,上面是Image,下面是Text,都是中心对齐的。

➤ 在CardBottomToolbar中,将HStack {改为:

HStack(alignment: .top) {

这将使按钮在HStack的顶部对齐。

Top aligned buttons

➤ 现在试试底部对齐。将对齐方式改为:

HStack(alignment: .bottom) {

这是最好的结果,因为现在所有的文本都是对齐的。

Bottom aligned buttons

当你在iPhone上建立并运行该应用程序,并旋转到横向时,你会看到图像逃过了栏的顶部,主栏覆盖了文本。

Escaping buttons

在第16章"为你的应用程序添加资产"中,你用紧凑和普通的尺寸类来决定使用哪种启动图像。在这里,你将在代码中检查设备的尺寸类别,并为每个尺寸类别使用不同的视图。紧凑尺寸类将只显示图像,而普通尺寸类将同时显示图像和文本。

➤ 还是在CardBottomToolbar.swift中,在ToolbarButtonView中,添加这个:

func regularView(
  _ imageName: String, 
  _ text: String
) -> some View {
  VStack(spacing: 2) {
    Image(systemName: imageName)
    Text(text)
  }
  .frame(minWidth: 60)
  .padding(.top, 5)
}

这是常规尺寸的类视图,同时显示图像和文本。

➤ 添加此方法:

func compactView(_ imageName: String) -> some View {
  VStack(spacing: 2) {
    Image(systemName: imageName)
  }
  .frame(minWidth: 60)
  .padding(.top, 5)
}

这是紧凑尺寸的类视图,只显示图像。

➤ 给ToolbarButtonView添加一个新的环境属性:

@Environment(\.verticalSizeClass) var verticalSizeClass

无论垂直尺寸类目前是紧凑的还是规则的,这个系统环境属性都成立。

➤ 将body替换为:

var body: some View {
  if let text = modalButton[modal]?.text,
    let imageName = modalButton[modal]?.imageName {
    if verticalSizeClass == .compact {
      compactView(imageName)
    } else {
      regularView(imageName, text)
    }
  }
}

这将为正确的尺寸类显示正确的视图。

➤ 在iPhone模拟器上构建并运行该应用程序,并打开一张卡片。当您旋转模拟器时,文本和图像都显示为纵向,但只有图像显示为横向。

Toolbar view dependent on size class

挑战

在第17章"与UIKit的交互"中,你实现了拖放功能。然而,当你放下一个项目时,它被添加到卡片的中心位置,偏移量为0

使用GeometryReader,你现在可以将拖放的位置转换成卡片上的正确偏移量。

  1. CardDrop, 丢弃委托,现在可以接受一个size和一个frame。在CardDetailViewbody中,改变.onDrop(of:delegate:),这样CardDrop就会收到计算好的卡片尺寸和全局坐标的框架。这就是proxy.frame(in: .global)
  2. CardDrop中,检查Dispatch.main.async闭包。offset是为你计算的,使用info.location。丢弃委托提供了这个丢弃位置。calculateOffset在坐标空间之间进行各种计算。

为了说明什么是坐标空间,所有卡片的偏移量都以卡片的中心为原点来保存。原点是位置(0, 0)。然而,info.location是屏幕坐标,原点是在屏幕的左上方。所以你必须从"屏幕空间"转换到"卡片空间"。

如果你需要提醒你如何进行拖放,请再看看第17章"与UIKit的接口"。

iPad上试试拖放,你就有无限多的谷歌图片来装饰你的卡片了。

Drag and Drop

关键点

  • 尽管你的应用程序可以使用,但在你的应用程序使用起来很有趣之前,你还没有完成。如果你没有一个专业的设计师,请尝试很多不同的设计和布局,直到有一个被选中。
  • SwiftUI的布局需要仔细考虑,因为有时它可能是不可预测的。黄金法则是,视图的尺寸来自其子代。
  • GeometryReader是一个视图,它在GeometryProxy中返回它的首选尺寸和框架。这意味着在GeometryReader视图层次中的任何视图都可以访问大小和框架来确定自己的大小。
  • 堆栈有对齐功能。如果这些还不够,你也可以创建你自己的自定义对齐方式。有一个很好的苹果WWDC视频,深入介绍了SwiftUI的布局系统:https://apple.co/39uamSx