更新於 2022 年 10 月 30 日

將 view 固定於螢幕邊緣

有時候我們會需要把某個介面固定在畫面上,方便使用者隨時使用。一個常見的是「Floating Button」,它是一個固定在螢幕的某個特定位置的按鈕。像是下圖的 Google 文件中的新增按鈕就是一個固定在右下角的 Floating Button。
Floating Button

這個設計讓我們隨時都能方便地新增文件,不過做這樣的設計的時候,有個特別要注意的地方,就是不要擋住原本畫面中的資訊
這個問題只會發生在畫面的角落,因為在中間的畫面就算被擋住,也可以透過滑動來讓它移動到可以看見、可以互動的位置。

截圖的時候發現 Google 文件就有這過問題,滾到最下面的時候就點不到最後的「...」圖示了。(可能是我太久沒更新 app)

最下端的更多資訊按鈕被擋住

用 overlay 實作的問題

要做 Floating Button 的時候,第一個直覺可能是使用 overlay,不過 overlay 就是會產生上面所說的問題。
我們用以下程式碼的話,拉到最下面就點不到最後一項商品旁邊的箭頭:
點不到最後一項商品旁邊的箭頭

struct FixedViewDemoView: View {
    var body: some View {
        ScrollView {
            ForEach(1...15, id: \.self, content: buildItemView)
        }
        .font(.title3)
        .padding(.horizontal)
        .background(Color(.secondarySystemBackground))
        .overlay(alignment: .bottomTrailing) { plusButton }
}
其他計算屬性
private func buildItemView(number: Int) -> some View {
    HStack {
        Text("商品 \(number)")
        Spacer()
        Image(systemName: "chevron.forward")
    }
    .padding()
    .background(.white, in: RoundedRectangle(cornerRadius: 8))
}

private var plusButton: some View {
    Image(systemName: "plus.circle.fill")
        .symbolRenderingMode(.palette)
        .foregroundStyle(.white, .indigo)
        .font(.system(size: 45).weight(.bold))
        .padding()
}

這是因為 overlay 本身就是「覆蓋」在上層畫面之上,所以這是可預期的正常結果。
我們可以在 ScrollView 的最下方加上一些空白,讓它多出一些額外的滑動空間,不過這樣還得再去抓需要多少額外空間,寫起來麻煩。

用 safeAreaInset 實作

在 iOS15,我們有一個新的調整器來幫助我們解決這個問題,就是「safeAreaInset」。
這個調整器可以把我們放進去的畫面對齊到指定的「安全線」,並且在對應的安全空間邊緣加上和這個畫面一樣大的空間。

我們只要把原本的 overlay 調整器換成這行:

.safeAreaInset(edge: .bottom, alignment: .trailing) { plusButton }

安全區就自動多了和按鈕畫面一樣高的留白空間了。
安全區自動增加留白空間

💡 safeAreaInset 的參數

edge:表示這個畫面要對齊的安全線
alignment:在這個指定的安全線上,畫面對齊的位置
spacing:除了畫面高度之外,額外加的空白
content:畫面

應用

除了用來做 Floating Button,也可以拿來做 Header、選單或聊天室的文字輸入框。
以下簡單示範把上面的畫面再加上一個固定在上方的標題欄和下方按鈕。

上下固定畫面

.safeAreaInset(edge: .top, spacing: 20) { header }
.safeAreaInset(edge: .bottom, spacing: 30) { bottomView }
其他計算屬性
private var gradient: some ShapeStyle {
    LinearGradient(colors: [.cyan, .mint],
                   startPoint: .topLeading, endPoint: .bottomTrailing)
}

private var header: some View {
    Text("商品清單")
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(gradient)
        .foregroundColor(.white)
        .font(.title.bold())
}

private var bottomView: some View {
    Text("查看購物車")
        .font(.title2.bold())
        .foregroundColor(.white)
        .padding(10)
        .frame(maxWidth: .infinity)
        .background(gradient,
                    in: RoundedRectangle(cornerRadius: 6))
        .padding(.horizontal, 30)
}