SwiftUI:如何控制safeAreaInset

WWDC21已经结束,safeAreaInset()是一个全新的SwiftUI视图修饰符,它允许我们定义成为观察安全区的一部分的视图。让我们深入研究这个新的、强大的特性。

滚动视图

最常见的safeAreaInset用例可能是滚动视图。以下面的屏幕为例,我们有一个带有一些内容的ScrollView和一个按钮:

button.png
struct ContentView: View {
  var body: some View {
    ScrollView {
      ForEach(1..<30) { _ in
        Text("Five Stars")
          .font(.largeTitle)
      }
      .frame(maxWidth: .infinity)
    }
    .overlay(alignment: .bottom) {
      Button {
        ...
      } label: {
        Text("Continue")
          .frame(maxWidth: .infinity)
      }
      .buttonStyle(.bordered)
      .controlSize(.large)
      .controlProminence(.increased)
      .padding(.horizontal)
    }
  }
}

注意:.buttonStyle(.bordered) .controlSize(.large) .controlProminence(.increased)是iOS15的视图修饰符

因为按钮只是一个覆盖,滚动视图不受它的影响,当我们滚动底部时,这就成为一个问题:

no.gif

ScrollView中的最后一个元素被遮挡在按钮下面!
现在我们把.overlay(alignment: .bottom).safeAreaInset(edge: .bottom)交换:

struct ContentView: View {
  var body: some View {
    ScrollView {
      ForEach(1..<30) { _ in
        Text("Five Stars")
          .font(.largeTitle)
      }
      .frame(maxWidth: .infinity)
    }
    .safeAreaInset(edge: .bottom) { // 👈🏻
      Button {
        ...
      } label: {
        Text("Continue")
          .frame(maxWidth: .infinity)
      }
      .buttonStyle(.bordered)
      .controlSize(.large)
      .controlProminence(.increased)
      .padding(.horizontal)
    }
  }
}

ScrollView观察通过safeAreaInset传递下来的新区域,最后的元素现在可见了:

yes.gif

接下来,让我们看看它是如何工作的。

定义

这个修饰符有两种变体,每个轴上有一个(水平/垂直):

/// Horizontal axis.
func safeAreaInset<V: View>(
  edge: HorizontalEdge,
  alignment: VerticalAlignment = .center,
  spacing: CGFloat? = nil,
  @ViewBuilder content: () -> V
) -> some View

/// Vertical axis.
func safeAreaInset<V: View>(
  edge: VerticalEdge, 
  alignment: HorizontalAlignment = .center, 
  spacing: CGFloat? = nil, 
  @ViewBuilder content: () -> V
) -> some View

它们有四个参数:

  • edge-指定目标区域的边缘,垂直方向上.top.bottom,水平方向.leading.trailing
  • alignment - 当safeAreaInset内容不适合可用空间时,我们指定如何对齐
  • spacing - 在那里我们可以进一步移动安全区超出safeAreaInset内容的边界,默认情况下,这个参数有一个非零值,基于我们的目标平台约定
  • content- 在这里定义safeAreaInset的内容

让我们在实践中使用它来理解这是怎么回事。

案例

默认情况下,SwiftUI将我们的内容放在安全区域,我们将从一个LinearGradient开始,它总是占用所有可用空间:

base.png

struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
  }
}

假设我们想要扩展顶部安全区域,这现在是可能的,感谢新的safeAreaInset视图修改器:

red.png
struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .safeAreaInset(edge: .top, spacing: 0) {
      Color.red.frame(height: 30).opacity(0.5)
    }
  }
}

我们传递了一个透明的视图作为视图修改器内容:注意LinearGradient是如何在它下面扩展的。

这是因为我们的safeAreaInset:

  1. 取观察区域
  2. 将其内容(上面的红色)放置在该区域(根据其参数)
  3. 基于content大小和参数,减少可用区域,并将其传递给LinearGradient

这是一个很大的区别与overlay视图修改器,其中:

  1. overlay应用于放置自身在观察区域
  2. overlay继承视图位置和大小
  3. overlay被放置在该空间的顶部

事物摆放的方式基本上是相反的。

Size

因为safeAreaInset只关心观察到的区域,它的content可以超过它应用到的视图的大小:

size.png
struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .frame(width: 50)
    .safeAreaInset(edge: .top, spacing: 0) {
      Color.red.frame(height: 30).opacity(0.5)
    }
  }
}

在这个例子中,受到.frame(width: 50)修饰符的影响,这个视图被safeAreaInset作用的区域只有50像素。然而,safeAreaInset的内容仍然占用了它所需要的观测区域的所有空间。

间距Spacing

spacing参数将进一步改变安全区域safeAreaInset内容的边界,在上面的例子中我们都是把它设置为0,这次我们把它设置为50:

gap.png
struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .safeAreaInset(edge: .top, spacing: 50) {
      Color.red.frame(height: 30).opacity(0.5)
    }
  }
}

现在在我们的Color.redLinearContent之间有50个点的差距:这个间距总是减少我们原始视图(例子中的LinearGradient)提供的区域,并且只针对我们的目标边缘。

如果我们传递一个负间距,那么我们将减少安全区域:

overlap.png
struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .safeAreaInset(edge: .top, spacing: -10) {
      Color.red.frame(height: 30).opacity(0.5)
    }
  }
}

正如所料,safeAreaInset内容没有移动,然而,LinearGradient现在重叠Color.red10个像素点,因为safeAreaInsetspacing-10

Alignment

alignment参数的工作原理类似于它在overlay上的做法,当safeAreaInset内容不完全适合可用空间时,将其定位在正确的位置。

使用Color.red.frame(height: 30),safeAreaInset内容总是占用所有的水平可用空间,让我们将其宽度限制为30,并声明一个.trailing对齐:

align.png
struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .safeAreaInset(edge: .top, alignment: .trailing, spacing: 0) {
      Color.red.frame(width: 30, height: 30)
    }
  }
}

在介绍完了之后,让我们尝试用我们的新修改器做更多的实验。

累积视图修饰符

当我们将多个safeAreaInset应用到同一个视图时会发生什么?

struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .safeAreaInset(edge: .bottom, spacing: 0) {
      Color.red.frame(height: 30).opacity(0.5)
    }
    .safeAreaInset(edge: .bottom, spacing: 0) {
      Color.green.frame(height: 30).opacity(0.5)
    }
    .safeAreaInset(edge: .bottom, spacing: 0) {
      Color.blue.frame(height: 30).opacity(0.5)
    }
  }
}

让我们回到文章的开头,我们描述了safeAreaInset的三个步骤:

  1. 取观察区域
  2. 将其内容(上面的红色)放置在该区域(根据其参数)
  3. 基于content大小和参数,减少可用区域,并将其传递给LinearGradient

第一个应用的视图修改器是最外面的一个,带有Color.blue那个,它执行上面的三个步骤,并将减少的可用区域向下传递到倒数第二个safeAreaInset,即Color.green,其他的也一样。

这是最终的结果:

stack.png

多个边缘

我们已经看到了如何“堆叠”多个safeAreaInsets,然而,我们不需要在一条边停止:完全可以应用多个safeAreaInset修改器,应用到不同的边:

struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .safeAreaInset(edge: .top, spacing: 0) {
      Color.red.frame(height: 30).opacity(0.5)
    }
    .safeAreaInset(edge: .trailing, spacing: 0) {
      Color.green.frame(width: 30).opacity(0.5)
    }
    .safeAreaInset(edge: .bottom, spacing: 0) {
      Color.blue.frame(height: 30).opacity(0.5)
    }
    .safeAreaInset(edge: .leading, spacing: 0) {
      Color.yellow.frame(width: 30).opacity(0.5)
    }
  }
}
multiple.png

同样的逻辑仍然有效,不管每个safeAreaInset修饰符的目标是什么边缘:

  • 首先我们应用/放置(最外面的)Color.yellow``safeAreaInset,它将占用所有需要的空间,并向下传递缩小的区域
  • 然后我们转到Color.blue``safeAreaInset也会做同样的事情

ignoresSafeArea

先前的ignoresSafeArea意味着让我们的视图被放置在Home指示符、键盘或状态栏下:
在iOS15中,ignoresSafeArea也意味着重置任何safeAreaInset

在下面的例子中,我们首先放置safeAreaInset,然后在放置最终视图之前忽略它:

ignore.png
struct ContentView: View {
  var body: some View {
    LinearGradient(
      colors: [.mint, .teal, .cyan, .indigo],
      startPoint: .leading,
      endPoint: .trailing
    )
    .ignoresSafeArea(edges: .bottom)
    .safeAreaInset(edge: .bottom, spacing: 0) {
      Color.red.frame(height: 30).opacity(0.5)
    }
  }
}

在Xcode 13b1,只有ScrollView正确地遵守了safeAreaInsets:希望列表和表单将在即将到来的Xcode种子中被修复

兼容iOS15之前的版本


safeAreaInset是iOS15才开始支持的API,那么如何在iOS13和14中使用相同的功能呢?

@available(iOS, introduced: 13, deprecated: 15, message: "Use .safeAreaInset() directly") // 👈🏻 2
extension View {
  @ViewBuilder
  func bottomSafeAreaInset<OverlayContent: View>(_ overlayContent: OverlayContent) -> some View {
    if #available(iOS 15.0, *) {
      self.safeAreaInset(edge: .bottom, spacing: 0, content: { overlayContent }) // 👈🏻 1
    } else {
      self.modifier(BottomInsetViewModifier(overlayContent: overlayContent))
    }
  }
}

我们希望在我们放弃对旧iOS版本的支持后,能够更容易地转移到SwiftUI的safeAreaInset

struct BottomInsetViewModifier<OverlayContent: View>: ViewModifier {
  @Environment(\.bottomSafeAreaInset) var ancestorBottomSafeAreaInset: CGFloat
  var overlayContent: OverlayContent
  @State var overlayContentHeight: CGFloat = 0

  func body(content: Self.Content) -> some View {
    content
      .environment(\.bottomSafeAreaInset, overlayContentHeight + ancestorBottomSafeAreaInset)
      .overlay(
        overlayContent
          .readHeight {
            overlayContentHeight = $0
          }
          .padding(.bottom, ancestorBottomSafeAreaInset)
        ,
        alignment: .bottom
      )
  }
}
extension View {
  func readHeight(onChange: @escaping (CGFloat) -> Void) -> some View {
    background(
      GeometryReader { geometryProxy in
        Spacer()
          .preference(
            key: HeightPreferenceKey.self,
            value: geometryProxy.size.height
          )
      }
    )
    .onPreferenceChange(HeightPreferenceKey.self, perform: onChange)
  }
}

private struct HeightPreferenceKey: PreferenceKey {
  static var defaultValue: CGFloat = .zero
  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

struct BottomSafeAreaInsetKey: EnvironmentKey {
  static var defaultValue: CGFloat = 0
}

extension EnvironmentValues {
  var bottomSafeAreaInset: CGFloat {
    get { self[BottomSafeAreaInsetKey.self] }
    set { self[BottomSafeAreaInsetKey.self] = newValue }
  }
}

struct ExtraBottomSafeAreaInset: View {
  @Environment(\.bottomSafeAreaInset) var bottomSafeAreaInset: CGFloat

  var body: some View {
    Spacer(minLength: bottomSafeAreaInset)
  }
}

使用案例如下:

stackSafeAreaInset.gif
struct ContentView: View {
  var body: some View {
    ScrollView {
      scrollViewContent
      ExtraBottomSafeAreaInset()
    }
    .bottomSafeAreaInset(overlayContent)
    .bottomSafeAreaInset(overlayContent)
    .bottomSafeAreaInset(overlayContent)
    .bottomSafeAreaInset(overlayContent)
    .bottomSafeAreaInset(overlayContent)
  }

  var scrollViewContent: some View {
    ForEach(1..<60) { _ in
      Text("Five Stars")
        .font(.title)
        .frame(maxWidth: .infinity)
    }
  }

  var overlayContent: some View {
    Button {
      // ...
    } label: {
      Text("Continue")
        .foregroundColor(.white)
        .padding()
        .frame(maxWidth: .infinity)
        .background(Color.accentColor.cornerRadius(8))
        .padding(.horizontal)
    }
  }
}

结论

WWDC21给我们带来了很多新的SwiftUI功能,让我们可以将我们的应用程序推向下一个层次:safeAreaInset是那些你不知道你需要的视图修改器之一,它有一个伟大的,简单的API。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容