Safe Area(安全区域)是指不与导航栏、标签栏、工具栏或其他视图控制器提供的视图重叠的内容空间。
在 UIKit 中,开发者需要利用 safeAreaInsets 或 safeAreaLayoutGuide ,才能确保将视图放置在界面中的可见部分。
SwiftUI 对上述过程进行了彻底的简化。除非开发者明确要求视图突破安全区域的限制,否则 SwfitUI 将尽力确保开发者创建的视图都被布局到安全区域当中。SwiftUI 同时提供了一些方法和工具让开发者对安全区域有所控制。
本文将探讨如何在 SwiftUI 中获取 SafeAreaInsets、将视图绘制到安全区域之外、修改视图的安全区域等内容。
如何获取 SafeAreaInsets
什么是 SafeAreaInsets
SafeAreaInsets 是用来确定视图安全区域的插入值。
对于根视图来说,safeAreaInsets 反映的是状态栏、导航栏、主页提示器以及 TabBar 等在各个边的占用数值。对于视图层次上的其他视图,safeAreaInesets 只反映视图中被覆盖的部分。如果一个视图可以完整地放置在父视图的安全区域中,该视图的 safeAreaInsets 为 0。当视图尚未在屏幕上可见时,该视图的 safeAreaInset 也为 0 。
在 SwiftUI 中,开发者通常只有在需要获取 StatusBar + NavBar 的高度或 HomeIndeicator + TabBar 的高度时才会使用到 safeAreaInsets 。
使用 GeometryReader 获取
GeometryProxy 提供了 safeAreaInsets 属性,开发者可以通过 GeometryReader 获取视图的 safeAreaInsets。
struct SafeAreaInsetsKey: PreferenceKey {
static var defaultValue = EdgeInsets()
static func reduce(value: inout EdgeInsets, nextValue: () -> EdgeInsets) {
value = nextValue()
}
}
extension View {
func getSafeAreaInsets(_ safeInsets: Binding<EdgeInsets>) -> some View {
background(
GeometryReader { proxy in
Color.clear
.preference(key: SafeAreaInsetsKey.self, value: proxy.safeAreaInsets)
}
.onPreferenceChange(SafeAreaInsetsKey.self) { value in
safeInsets.wrappedValue = value
}
)
}
}
使用方式:
struct GetSafeArea: View {
@State var safeAreaInsets: EdgeInsets = .init()
var body: some View {
NavigationView {
VStack {
Color.blue
}
}
.getSafeAreaInsets($safeAreaInsets)
}
}
// iphone 13
// EdgeInsets(top: 47.0, leading: 0.0, bottom: 34.0, trailing: 0.0)
从获得的 insets 可以得知 HomeIndeicator 的高度为 34。
也可以使用下面的代码,进一步了解 safeAreaInsets 在各个层级视图中的状况。
extension View {
func printSafeAreaInsets(id: String) -> some View {
background(
GeometryReader { proxy in
Color.clear
.preference(key: SafeAreaInsetsKey.self, value: proxy.safeAreaInsets)
}
.onPreferenceChange(SafeAreaInsetsKey.self) { value in
print("\(id) insets:\(value)")
}
)
}
}
例如:
struct GetSafeArea: View {
var body: some View {
NavigationView {
VStack {
Text("Hello world")
.printSafeAreaInsets(id: "Text")
}
}
.printSafeAreaInsets(id: "NavigationView")
}
}
// iPhone 13 pro
// NavigationView insets:EdgeInsets(top: 47.0, leading: 0.0, bottom: 34.0, trailing: 0.0)
// Text insets:EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0)
从 KeyWindow 中获取
如果只需要获取根视图的 safeAreaInsets ,我们也可以使用更加直接的方式。
下面代码取自 StackOverFlow 网友 Mirko 的答案
extension UIApplication {
var currentWindow: UIWindow? {
connectedScenes
.compactMap {
$0 as? UIWindowScene
}
.flatMap {
$0.windows
}
.first {
$0.isKeyWindow
}
}
}
private struct SafeAreaInsetsKey: EnvironmentKey {
static var defaultValue: EdgeInsets {
UIApplication.shared.currentWindow?.safeAreaInsets.swiftUiInsets ?? EdgeInsets()
}
}
extension EnvironmentValues {
var safeAreaInsets: EdgeInsets {
self[SafeAreaInsetsKey.self]
}
}
private extension UIEdgeInsets {
var swiftUiInsets: EdgeInsets {
EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
}
}
可以通过环境值获取到根视图的 safeAreaInsets :
@Environment(\.safeAreaInsets) private var safeAreaInsets
使用 ignoresSafeArea 忽略安全区域
在开发 iOS 应用时,经常会碰到需要让视图可以扩展到非安全区域的情况。例如,希望让背景颜色充满整个屏幕。
struct FullScreenView: View {
var body: some View {
ZStack {
Color.blue
Text("Hello world").foregroundColor(.white)
}
}
}
由于 SwiftUI 在默认的情况下会将用户视图置于安全区之内,因此我们只能得到如下的结果:
为了让视图能够突破安全区域的限制,SwiftUI 提供了 ignoresSafeArea 修饰器。
struct FullScreenView: View {
var body: some View {
ZStack {
Color.blue
Text("Hello world").foregroundColor(.white)
}
.ignoresSafeArea() // 全方向忽略安全区域
}
}
iOS 13 提供的 edgesIgnoringSafeArea 修饰器已经在 iOS 14.5 中弃用。
ignoresSafeArea 的定义如下:
@inlinable public func ignoresSafeArea(_ regions: SafeAreaRegions = .all, edges: Edge.Set = .all) -> some View
在默认情况下, .ignoresSafeArea()
代表着在全部方向,忽略全部的安全区域划分(SafeAreaRegions)。
通过指定 edges,我们可以让某个或某几个边突破安全区域的限制。
// 只扩展到底部
.ignoresSafeArea(edges: .bottom)
// 扩展到顶部和底部
.ignoresSafeArea(edges: [.bottom, .trailing])
// 横向扩展
.ignoresSafeArea(edges:.horizontal)
使用起来非常直观、方便,但为什么视图会在有键盘输入时出现不符合预期的行为?这是因为,我们并没有正确的设置 ignoresSafeArea 另一个重要的参数 regions
。
ignoresSafeArea 相较于 SwiftUI 1.0 提供的 edgesIgnoringSafeArea 最大的提升便是允许我们设置 SafeAreaRegions 。
SafeAreaRegions 定义了三种安全区域划分:
-
container
由设备和用户界面内的容器所定义的安全区域,包括诸如顶部和底部栏等元素。
-
keyboard
与显示在视图内容上的任何软键盘的当前范围相匹配的安全区域。
-
all(默认)
上述两种安全区域划分的合集
iOS 13 并没有提供键盘自动避让功能,开发者需要编写一些额外的代码来解决软键盘不恰当遮盖视图(如 TextField )的问题。
从 iOS 14 开始,SwiftUI 计算视图的安全区域时,将软键盘在屏幕上的覆盖区域(iPadOS 下,将软键盘缩小后键盘的覆盖区域将被忽略)也一并进行考虑。因此,无需使用任何额外的代码,视图便自动获得了键盘避让的能力。但有时,并非所有的视图都需要将软键盘的覆盖区域从安全区域中去除,因此需要正确地设置 SafeAreaRegions 。
struct IgnoresSafeAreaTest: View {
var body: some View {
ZStack {
// 渐变背景
Rectangle()
.fill(.linearGradient(.init(colors: [.red, .blue, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing))
VStack {
// Logo
Circle().fill(.regularMaterial).frame(width: 100, height: 100).padding(.vertical, 100)
// 文本输入
TextField("name", text: .constant(""))
.textFieldStyle(.roundedBorder)
.padding()
}
}
}
}
上面的代码尽管实现了键盘的自动避让,但并不完全符合预期行为。首先,背景并没有充满全部屏幕,其次在软键盘弹出时,我们并不希望背景因为安全区域的变化而发生改变。尽管通过 ignoresSafeArea 可以解决上述问题,但在什么位置添加、如何设定还是有一点讲究的。
我们将 ignoresSafeArea 添加到 ZStack 之后:
ZStack {
...
}
.ignoresSafeArea()
此时,背景充满了屏幕,也不受软键盘弹出的影响了。但前景的内容失去了键盘自动避让的能力。
如果将代码修改成:
ZStack {
...
}
.ignoresSafeArea(.container)
此时,背景充满了屏幕,前景支持了键盘避让,但背景会在键盘出现时,发生了不该有的变化。
正确的处理方式是,只让背景忽略安全区域:
struct IgnoresSafeAreaTest: View {
var body: some View {
ZStack {
Rectangle()
.fill(.linearGradient(.init(colors: [.red, .blue, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing))
.ignoresSafeArea(.all) // 只让背景忽略安全区域
VStack {
Circle().fill(.regularMaterial).frame(width: 100, height: 100).padding(.vertical, 100)
TextField("name", text: .constant(""))
.textFieldStyle(.roundedBorder)
.padding()
}
}
}
}
除了需要对正确的视图设定正确的 ignoresSafeArea 参数外,有时为了获得满意的结果,适当地调整视图的组织形式也是不错的选择。
使用 safeAreaInset 扩展安全区域
在 SwiftUI 中,所有基于 UIScrollView 的组件(ScrollView、List、Form),在默认情况下都会充满整个屏幕,但仍可确保我们可以在安全区域内看到所有的内容。
List(0..<100){ id in
Text("id\(id)")
}
当被嵌入到 TabView 时,TabView 会调整其内部的安全区域。
遗憾的是,在 iOS 15 之前,SwiftUI 并没有提供调整视图安全区的手段,如果我们想通过 SwiftUI 的手段创建一个自定义 Tabbar 时,列表中最后的内容将被 Tabbar 遮挡。
safeAreaInset 修饰符的出现解决了上述的问题。通过 safeAreaInset,我们可以缩小视图的安全区域,以确保所有内容都可以按预期显示。
例如:
struct AddSafeAreaDemo: View {
var body: some View {
ZStack {
Color.yellow.border(.red, width: 10)
}
.safeAreaInset(edge: .bottom, alignment: .center, spacing: 0) {
Rectangle().fill(.blue)
.frame(height: 100)
}
.ignoresSafeArea()
}
}
我们使用了 safeAreaInset,将 ZStack 内部的安全区从底边缩小了 100,并在此处显示了一个蓝色的矩形。
利用 safeAreaInset,可以让 List 在自定义的 TabBar 中表现同系统 TabBar 一致的行为。
struct AddSafeAreaDemo: View {
var body: some View {
NavigationView {
List(0..<100) { i in
Text("id:\(i)")
}
.safeAreaInset(edge: .bottom, spacing: 0) {
Text("底部状态条")
.font(.title3)
.foregroundColor(.indigo)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40)
.padding()
.background(.green.opacity(0.6))
}
}
}
}
在 iPhone 13 下的表现
我们只调整了安全区域, SwiftUI 会自动在不同的设备上进行适配(在 iPhone 13 上,状态条的高度为 40 + HomeIndeicator 区域高度)。
自动适配只对 background 有效。
相同的代码,在 iPhone 8 下的表现
iOS 15.2 之前的版本,safeAreaInset 对 List 和 Form 的支持有问题(ScrollView 表现正常),无法将列表最后的内容全部显示完整。该 Bug 已在 iOS 15.2 中得到了修复。本文中的代码,在 Xcode 13.2 beta (13 C 5066 c) 之后的版本都可以获得符合预期的表现。
safeAreaInset 可以叠加,这样我们可以在多个边对安全区域进行调整,例如:
ZStack {
Color.yellow.border(.red, width: 10)
}
.safeAreaInset(edge: .bottom, alignment: .center, spacing: 0) {
Rectangle().fill(.blue)
.frame(height: 100)
}
.safeAreaInset(edge: .trailing, alignment: .center, spacing: 0) {
Rectangle().fill(.blue)
.frame(width: 50)
}
我们也可以通过 aligmnet 为安全区域插入内容设置对齐方式,用 spacing 在想要显示的内容和安全区域添加内容之间添加额外的空间。
尽管使用 safeAreaInset 为列表在底部添加状态栏或自定义 TabBar 非常方便,但如果你的列表中使用了 TextField,情况将变得很麻烦。
比如,下面是一个很极端的例子:
struct AddSafeAreaDemo: View {
var body: some View {
ScrollView {
ForEach(0..<100) { i in
TextField("input text for id:\(i)",text:.constant(""))
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
Text("底部状态条")
.font(.title3)
.foregroundColor(.indigo)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40)
.padding()
.background(.green.opacity(0.6))
.ignoresSafeArea(.all)
}
}
}
我们是无法通过使用 ignoresSafeArea,让 TextField 在保持对键盘自动避让的情况下,固定底部的状态条。此时,底部状态条的表现肯定不符合设计的初衷。
如果想让底部状态条固定,同时又保持 TextField 的自动避让能力,需要通过监控键盘的状态,做一点额外的操作。
final class KeyboardMonitor: ObservableObject {
@Published var willShow: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification, object: nil)
.sink { _ in
self.willShow = true
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification, object: nil)
.sink { _ in
self.willShow = false
}
.store(in: &cancellables)
}
}
struct AddSafeAreaDemo: View {
@StateObject var monitor = KeyboardMonitor()
var body: some View {
ScrollView {
ForEach(0..<100) { i in
TextField("input text for id:\(i)", text: .constant(""))
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if !monitor.willShow { // 在键盘即将弹出时隐藏
Text("底部状态条")
.font(.title3)
.foregroundColor(.indigo)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40)
.padding()
.background(.green.opacity(0.6))
.ignoresSafeArea(.all)
}
}
}
}
如果上述的代码被放置在 NavigationView 中,还需要对底部状态条动画做更加精细地处理。
实战:用 safeAreaInset 实现类似微信的对话页面
使用 safeAreaInset,我们只需很少的代码便可以实现一个类似微信的对话页面。
struct ChatBarDemo: View {
@State var messages: [Message] = (0...60).map { Message(text: "message:\($0)") }
@State var text = ""
@FocusState var focused: Bool
@State var bottomTrigger = false
var body: some View {
NavigationView {
ScrollViewReader { proxy in
List {
ForEach(messages) { message in
Text(message.text)
.id(message.id)
}
}
.listStyle(.inset)
.safeAreaInset(edge: .bottom) {
ZStack(alignment: .top) {
Color.clear
Rectangle().fill(.secondary).opacity(0.3).frame(height: 0.6) // 上部线条
HStack(alignment: .firstTextBaseline) {
// 输入框
TextField("输入", text: $text)
.focused($focused)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 10)
.padding(.top, 10)
.onSubmit {
addMessage()
scrollToBottom()
}
.onChange(of: focused) { value in
if value {
scrollToBottom()
}
}
// 回复按钮
Button("回复") {
addMessage()
scrollToBottom()
focused = false
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.green)
}
.padding(.horizontal, 30)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 53)
.background(.regularMaterial)
}
.onChange(of: bottomTrigger) { _ in
withAnimation(.spring()) {
if let last = messages.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.onAppear {
if let last = messages.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.navigationBarTitle("SafeArea Chat Demo")
}
}
func scrollToBottom() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
bottomTrigger.toggle()
}
}
func addMessage() {
if !text.isEmpty {
withAnimation {
messages.append(Message(text: text))
}
text = ""
}
}
}
struct Message: Identifiable, Hashable {
let id = UUID()
let text: String
}
总结
在 SwiftUI 中,有不少的功能都属于看一眼就会,但用起来就废的情况。即使表面上平平无奇的功能,仔细深挖仍可获得不少收获。