更新於 2022 年 10 月 09 日

在 SwiftUI 中替 View 加上陰影

在 SwiftUI 中,我們可以輕鬆地替 View 加上陰影,只要加上 .shadow 調整器就可以動態產生陰影。iOS16 更是新增了方便的內陰影 API,產生有立體、層次感的畫面變得更容易。

 外陰影

產生一個在右下方的陰影

Rectangle()
    .foregroundColor(.teal)
    .frame(width: 100, height: 100)
    .shadow(radius: 3, x: 6, y: 6)
    // 你也可以再加上顏色參數

整體陰影

有些時候你可能想替一個 Layout View 加上陰影,但卻發現連子畫面都被加上陰影了。

VStack(spacing: 30) {
    Text("Hello")
    Image(systemName: "fish")
}
.font(.largeTitle.bold())
.foregroundColor(.teal)
.padding(40)
.border(.teal, width: 3)
.shadow(radius: 3, x: 6, y: 6) // ?!

這個情況有兩個要注意的地方:

  1. VStack 本身並沒有具體的「佔位形狀」,現在畫面上出現方框陰影只是因為我們用了「border」(邊框)調整器而產生的邊框陰影。所以首先要先把 VStack 填滿,讓它有一個完整的形狀。
    ⬇️ 左邊是加上了半透明的背景的結果,跟右邊原本的陰影比較可以看到陰影形狀的差別。

  1. 陰影調整器會影響所有子畫面。就像太陽光照到地球不是只有地球本身產生陰影,而是地球上的萬物都產生陰影。如果我們不想要每一個 View 都產生陰影,我們可以使用 compositingGroup 這個調整器把相關畫面打包成單一個 View 之後再使用 shadow,就可以讓整個 Layout 只產生一個陰影。

VStack(spacing: 30) {
    Text("Hello")
    Image(systemName: "fish")
}
.font(.largeTitle.bold())
.foregroundColor(.teal)
.padding(40)
.border(.teal, width: 3)
.background(.white) // 1. 加上背景
.compositingGroup() // 2. 把這一行前面的畫面打包成單一個 View
.shadow(radius: 3, x: 6, y: 6)
// 3. 最後這個陰影就只會賦予打包出來的那一個 view 陰影了
// 💡 注意這三個調整器的先後順序很重要,要看懂為什麼是這個順序哦

 內陰影

iOS16 新增了方便產生內陰影的方法,用法和外陰影有一點點不一樣。
外陰影你可以直接套用在任何 View 上面,但是內陰影只能套用在 ShapeStyle 的畫面上,畢竟內陰影需要某種空間才能成立。

💡 顏色、形狀、漸層都有遵循 ShapeStyle。

    VStack(spacing: 30) {
        Text("Hello")
        Image(systemName: "fish")
    }
    .font(.largeTitle.bold())
    .foregroundColor(.teal)
    .padding(40)
    .border(.teal, width: 3)
    .background(
        .white
            .shadow(
                .inner(color: .green, radius: 10))
                // 在背景的白色色塊加上綠色內陰影
    )
    .compositingGroup()
    .shadow(radius: 3, x: 6, y: 6)

如果是想在一般的 View 中使用,可以搭配 foregroundStyle

⬇️ 上面是單純的橘色,下面是加上內陰影的橘色。

VStack {
    Text("Hello")
        .foregroundStyle(.orange)
    Text("Hello")
        .foregroundStyle(.orange.shadow(.inner(radius: 3)))
} .font(.system(size: 50, weight: .black))

 Neumorphic(擬物)風格

有一段時間很熱門的 Neumorphic 風格需要內陰影和外陰影搭配。用新的內陰影 API 寫起來短一點,但是很可惜顏色不能用漸層色,所以效果有限。

⬇️ 右邊是 isPressed 的情況。

🎨 範例的顏色色碼:

main - #ECF0F3
lightShadow - #FFFFFF
darkShadow - #D1D9E6

Text("Hello")
    .foregroundColor(.teal)
    .padding(.vertical, 25)
    .padding(.horizontal, 50)
    .scaleEffect(isPressed ? 0.98 : 1) // 按下時會稍微小一點
    .animation(.easeOut, value: isPressed)
    .background(
        RoundedRectangle(cornerRadius: 8)
            .foregroundStyle(
                Color.main // 按下時變成內陰影
                    .shadow(isPressed ?
                                .inner(color: .darkShadow, radius: 4, x: 3, y: 3)
                                : .drop(color: .darkShadow, radius: 10, x: 10, y: 10))
                    .shadow(isPressed ?
                                .inner(color: .lightShadow, radius: 4, x: -3, y: -3)
                                : .drop(color: .lightShadow, radius: 10, x: -5, y: -5))
            )
    )
    .onTapGesture { isPressed.toggle() }
    .font(.system(size: 50, weight: .black))
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color.main)

如果想用 Neumorphic 風格,內陰影還是用舊的 stroke + 漸層 mask 比較好看。
雖然現在這風格比較少用了,但還是寫來練習陰影 & mask 效果還是很有價值的。如果有興趣可以參考這個 repo

陰影的效能

有時候你可能會看到 SwiftUI 警告你動態陰影的效能問題——

The layer is using dynamic shadows which are expensive to render.

如果實際使用上沒有問題就不用擔心。如果真的遇到卡卡的,你應該先考慮把 Layout 變成 Lazy 的,再考慮改變陰影的寫法。因為除非你是把陰影用來畫圖,否則需要一次 render 大量陰影的情況一定和你一次產生的 view 的數量有關。