A Safe Area is a content space that does not overlap with the view provided by the navigation bar, TAB bar, toolbar, or other view controller.

In UIKit, developers need to utilize safeAreaInsets or safeAreaLayoutGuide to ensure that the view is placed in the visible part of the interface.

SwiftUI radically simplifies the above process. SwfitUI will try to ensure that all views created by the developer are laid out into a safe zone unless the developer explicitly asks the view to break through the safe zone. SwiftUI also provides methods and tools to give developers control over secure areas.

This article explores how to get SafeAreaInsets in SwiftUI, draw a view outside of a security zone, modify a view’s security zone, and more.

The original post was posted on my blog wwww.fatbobman.com

Welcome to subscribe my public account: [Elbow’s Swift Notepad]

How to obtain SafeAreaInsets

What is a SafeAreaInsets

SafeAreaInsets are used to determine the insertion values of the view’s security zone.

For the root view, safeAreaInsets reflect the number of edges occupied by the status bar, navigation bar, home page prompter, TabBar, and so on. For other views at the view level, safeAreaInesets reflects only the overridden portions of the view. If a view can be placed intact in the security zone of the parent view, the safeAreaInsets of that view are 0. When a view is not yet visible on the screen, its safeAreaInset is also 0.

In SwiftUI, developers typically use safeAreaInsets only when they need to get the height of StatusBar + NavBar or HomeIndeicator + TabBar.

Get using an GeometryReader

GeometryProxy provides the safeAreaInsets property, which enables the developer to obtain the view’s safeAreaInsets.

struct SafeAreaInsetsKey: PreferenceKey {
    static var defaultValue = EdgeInsets(a)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
            }
        )
    }
}
Copy the code

Usage:

struct GetSafeArea: View {
    @State var safeAreaInsets: EdgeInsets = .init(a)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)
Copy the code

From the obtained Insets, it can be known that the height of HomeIndeicator is 34.

You can also use the following code to learn more about safeAreaInsets in a hierarchical view.

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)")})}}Copy the code

Such as:

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)
// set (top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0)
Copy the code

Get from KeyWindow

If we just need to get the safeAreaInsets of the root view, we can also use a more direct approach.

The following code is an answer from StackOverFlow user Mirko

extension UIApplication {
    var keyWindow: UIWindow? {
        connectedScenes
            .compactMap {
                $0 as? UIWindowScene
            }
            .flatMap {
                $0.windows
            }
            .first {
                $0.isKeyWindow
            }
    }
}

private struct SafeAreaInsetsKey: EnvironmentKey {
    static var defaultValue: EdgeInsets {
        UIApplication.shared.keyWindow?.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)
    }
}
Copy the code

The safeAreaInsets of the root view can be obtained from the environment value:

@Environment(\.safeAreaInsets) private var safeAreaInsets
Copy the code

Use ignoresSafeArea to ignore security zones

When developing iOS applications, you often encounter situations where you need to extend views to non-secure areas. For example, you want the background color to fill the entire screen.

struct FullScreenView: View {
    var body: some View {
        ZStack {
            Color.blue
            Text("Hello world").foregroundColor(.white)
        }
    }
}
Copy the code

Since SwiftUI places the user view in a secure zone by default, we can only get the following result:

To enable views to break out of security zones, SwiftUI provides the ignoresSafeArea decorator.

struct FullScreenView: View {
    var body: some View {
        ZStack {
            Color.blue
            Text("Hello world").foregroundColor(.white)
        }
        .ignoresSafeArea() // Ignore the security zone in all directions}}Copy the code

The edgesIgnoringSafeArea decorator provided by iOS 13 has been deprecated in iOS 14.5.

IgnoresSafeArea is defined as follows:

@inlinable public func ignoresSafeArea(_ regions: SafeAreaRegions = .all, edges: Edge.Set = .all) -> some View
Copy the code

By default,.ignoresSafeArea() represents all safearegions ignored in all directions.

By specifying edges, we can allow one or more edges to break out of the security zone.

// Only extend to the bottom
.ignoresSafeArea(edges: .bottom)

// Expand to the top and bottom
.ignoresSafeArea(edges: [.bottom, .trailing])

// Scale horizontally
.ignoresSafeArea(edges:.horizontal)
Copy the code

It’s intuitive and easy to use, but why do views behave differently when there’s keyboard input? This is because we did not correctly set the ignoresSafeArea parameter regions, another important parameter.

IgnoresSafeArea’s biggest improvement over the edgesIgnoringSafeArea provided by SwiftUI 1.0 is that it allows us to set SafeAreaRegions.

Safearegions defines three security zone divisions:

  • container

    Security zones defined by containers within the device and user interface, including elements such as the top and bottom bars.

  • keyboard

    A security zone that matches the current range of any soft keyboard displayed on the view content.

  • All (default)

    A combination of the two security zones described above

IOS 13 doesn’t offer keyboard auto-dodge, and developers need to write some extra code to address the issue of the soft keyboard improperly masking views like TextField.

Starting with iOS 14, SwiftUI computes the safe zone of the view and also considers the soft keyboard’s on-screen coverage area (under iPadOS, the keyboard’s coverage area is ignored when the soft keyboard is shrunk). Therefore, the view automatically acquires the ability to avoid the keyboard without using any additional code. Sometimes, however, not all views need to remove the soft keyboard’s coverage area from the security zone, so safeAreGions need to be set up correctly.

struct IgnoresSafeAreaTest: View {
    var body: some View {
        ZStack {
            // Gradient background
            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)
                // Text input
                TextField("name", text: .constant(""))
                    .textFieldStyle(.roundedBorder)
                    .padding()
            }
        }
    }
}
Copy the code

The code above, although it implements automatic avoidance of the keyboard, does not fully behave as expected. First, the background didn’t fill the screen, and second, when the soft keyboard popped up, we didn’t want the background to change because the security zone changed. While ignoresSafeArea can be used to address these issues, there is a little bit of discretion about where to add and how to set them.

We add ignoresSafeArea to the ZStack:

ZStack {
    .
}
.ignoresSafeArea()
Copy the code

At this point, the background fills the screen and is not affected by the soft keyboard pop-up. But foreground content loses the keyboard’s ability to automatically dodge.

If you change the code to:

ZStack {
    .
}
.ignoresSafeArea(.container)
Copy the code

At this point, the background fills the screen, and the foreground supports keyboard avoidance, but the background changes when the keyboard appears.

The correct way to do this is to just have the background ignore the security zone:

struct IgnoresSafeAreaTest: View {
    var body: some View {
        ZStack {
            Rectangle()
                .fill(.linearGradient(.init(colors: [.red, .blue, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing))
                .ignoresSafeArea(.all) // Just let the background ignore the security zone
            VStack {
                Circle().fill(.regularMaterial).frame(width: 100, height: 100).padding(.vertical, 100)
                TextField("name", text: .constant(""))
                    .textFieldStyle(.roundedBorder)
                    .padding()
            }
        }
    }
}
Copy the code

In addition to the need to set the correct ignoresSafeArea parameter for the right view, it is sometimes a good idea to adjust the organization of the view to achieve satisfactory results.

Extend the security zone with safeAreaInset

In SwiftUI, all uiscrollView-based components (ScrollView, List, Form) fill the entire screen by default, but still ensure that all content can be seen within a safe area.

List(0..<100){ id in
    Text("id\(id)")}Copy the code

When embedded in a TabView, the TabView adjusts its internal security zone.

Unfortunately, before iOS 15, SwiftUI didn’t provide a way to adjust view security, so if we wanted to create a custom Tabbar using SwiftUI, the Tabbar would block the last thing in the list.

The safeAreaInset modifier solves this problem. With safeAreaInset, we can narrow the security zone of the view to ensure that everything is displayed as expected.

Such as:

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()
    }
}
Copy the code

We used safeAreaInset to shrink the security zone inside the ZStack by 100 from the bottom and show a blue rectangle there.

With safeAreaInset, you can have a List behave in a custom TabBar in a manner consistent with the system TabBar.

struct AddSafeAreaDemo: View {
    var body: some View {
        NavigationView {
            List(0..<100) { i in
                Text("id:\(i)")
            }
            .safeAreaInset(edge: .bottom, spacing: 0) {
                Text("Bottom status bar")
                    .font(.title3)
                    .foregroundColor(.indigo)
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40)
                    .padding()
                    .background(.green.opacity(0.6)}}}}Copy the code

On the iPhone 13

We only adjusted the security zone, SwiftUI will automatically adapt across devices (on the iPhone 13, the status bar is 40 + HomeIndeicator zone height).

Automatic adaptation only works for background.

The same code, how it works on the iPhone 8

In iOS versions prior to 15.2, safeAreaInset had a problem with support for lists and forms (ScrollView performed well) and could not display all the contents at the end of the List completely. The Bug has been fixed in iOS 15.2. The code in this article will perform as expected in versions after Xcode 13.2 Beta (13C5066c).

SafeAreaInset can be superimposed so that we can adjust the security zone on multiple sides, for example:

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)}Copy the code

We can also use Aligmnet to set alignment for security zone inserts and use spacing to add extra space between what we want to display and what we want to add to the security zone.

While it’s convenient to use safeAreaInset to add a status bar or custom TabBar at the bottom of your list, it can get cumbersome if you use TextField in your list.

For example, here’s an extreme example:

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("Bottom status bar")
                .font(.title3)
                .foregroundColor(.indigo)
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40)
                .padding()
                .background(.green.opacity(0.6))
                .ignoresSafeArea(.all)
        }
    }
}
Copy the code

There is no way to fix the bottom status bar of the TextField by using ignoresSafeArea while keeping it automatically hidden from the keyboard. At this point, the performance of the bottom status bar is definitely not in line with the original design.

If you want to keep the bottom status bar fixed while maintaining the automatic avoidance capability of the TextField, you need to do a little extra by monitoring the state of the keyboard.

final class KeyboardMonitor: ObservableObject {
    @Published var willShow: Bool = false
    private var cancellables = Set<AnyCancellable> ()init(a) {
        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(a)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 { // Hide when the keyboard is about to pop up
                Text("Bottom status bar")
                    .font(.title3)
                    .foregroundColor(.indigo)
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40)
                    .padding()
                    .background(.green.opacity(0.6))
                    .ignoresSafeArea(.all)
            }
        }
    }
}
Copy the code

If the above code is placed in NavigationView, the bottom status bar animation needs to be handled more carefully.

Actual combat: Use safeAreaInset to achieve similar wechat dialogue pages

With safeAreaInset, we can implement a wechat-like chat page with very little code.

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) // Upper line
                        HStack(alignment: .firstTextBaseline) {
                            / / input box
                            TextField("Input", text: $text)
                                .focused($focused)
                                .textFieldStyle(.roundedBorder)
                                .padding(.horizontal, 10)
                                .padding(.top, 10)
                                .onSubmit {
                                    addMessage()
                                    scrollToBottom()
                                }
                                .onChange(of: focused) { value in
                                    if value {
                                        scrollToBottom()
                                    }
                                }
                            // Reply button
                            Button("Reply") {
                                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(a) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
            bottomTrigger.toggle()
        }
    }

    func addMessage(a) {
        if !text.isEmpty {
            withAnimation {
                messages.append(Message(text: text))
            }
            text = ""}}}struct Message: Identifiable.Hashable {
    let id = UUID(a)let text: String
}
Copy the code

conclusion

There are many features in SwiftUI that are easy to look at but useless to use. Even seemingly unremarkable features, there’s still a lot to be gained by digging deeper.

I hope this article has been helpful to you.

The original post was posted on my blog wwww.fatbobman.com

Welcome to subscribe my public account: [Elbow’s Swift Notepad]