自定義 Button 的外觀和互動行為

語言: CN / TW / HK

highlight: a11y-dark

通過 Style 改變元件的外觀或行為是 SwiftUI 提供的一項非常強大的功能。本文將介紹如何通過建立符合 ButtonStyle 或 PrimitiveButtonStyle 協議的實現,自定義 Button 的外觀以及互動行為。

可在 此處 獲取本文的範例程式碼

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

定製 Button 的外觀

按鈕是 UI 設計中經常會使用到的元件。相較於 UIKit ,SwiftUI 通過 Button 檢視,讓開發者以少量的程式碼便可完成按鈕的建立工作。

swift Button(action: signIn) { Text("Sign In") }

多數情況下,開發者通過為 Button 的 label 引數提供不同的檢視來定製按鈕的外觀。

```swift struct RoundedAndShadowButton:View where V:View { let label:V let action: () -> Void init(label: V, action: @escaping () -> Void) { self.label = label self.action = action } var body: some View { Button { action() } label: { label .foregroundColor(.white) .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .background( RoundedRectangle(cornerRadius: 10) .foregroundColor(.blue) ) .compositingGroup() .shadow(radius: 5,x:0,y:3) .contentShape(Rectangle()) } .buttonStyle(.plain) } }

let label = Label("Press Me", systemImage: "digitalcrown.horizontal.press.fill")

RoundedAndShadowButton(label: label, action: { pressAction("button view") }) ```

buttonView_2023-02-15_17.36.59.2023-02-15 17_38_28

使用 ButtonStyle 定製互動動畫

遺憾的是,上面的程式碼無法修改按鈕在點選後的按壓效果。幸好,SwiftUI 提供了 ButtonStyle 協議可以幫助我們定製互動動畫。

```swift public protocol ButtonStyle { @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body typealias Configuration = ButtonStyleConfiguration }

public struct ButtonStyleConfiguration { public let role: ButtonRole? public let label: ButtonStyleConfiguration.Label public let isPressed: Bool } ```

ButtonStyle 協議的使用方式與 ViewModifier 十分類似。通過 ButtonStyleConfiguration 提供的資訊,開發者只需實現 makeBody 方法,即可完成互動動畫的定製工作。

  • label:目標按鈕的當前檢視,通常對應著 Button 檢視中的 label 引數內容
  • role:iOS 15 後新增的引數,用於標識按鈕的角色( 取消或具備破壞性)
  • isPressed:當前按鈕的按壓狀態,該資訊是多數人使用 ButtonStyle 的原動力

```swift struct RoundedAndShadowButtonStyle:ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(.white) .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .background( RoundedRectangle(cornerRadius: 10) .foregroundColor(.blue) ) .compositingGroup() // 根據 isPressing 來調整互動動畫 .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3) .scaleEffect(configuration.isPressed ? 0.95 : 1) .animation(.spring(), value: configuration.isPressed) } }

// 快捷引用 extension ButtonStyle where Self == RoundedAndShadowButtonStyle { static var roundedAndShadow:RoundedAndShadowButtonStyle { RoundedAndShadowButtonStyle() } } ```

通過 buttonStyle 修飾器應用於 Button 檢視

swift Button(action: { pressAction("rounded and shadow") }, label: { label }) .buttonStyle(.roundedAndShadow)

buttonStyle1_2023-02-15_18.27.17.2023-02-15 18_28_25

建立一個通用性好 ButtonStyle 實現需要考慮很多條件,例如:role、controlSize、動態字型尺寸、色彩模式等等方面。同 ViewModifier 一樣,可以通過環境值獲取更多資訊:

```swift struct RoundedAndShadowProButtonStyle:ButtonStyle { @Environment(.controlSize) var controlSize func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(.white) .padding(getPadding()) .font(getFontSize()) .background( RoundedRectangle(cornerRadius: 10) .foregroundColor( configuration.role == .destructive ? .red : .blue) ) .compositingGroup() .overlay( VStack { if configuration.isPressed { RoundedRectangle(cornerRadius: 10) .fill(Color.white.opacity(0.5)) .blendMode(.hue) } } ) .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3) .scaleEffect(configuration.isPressed ? 0.95 : 1) .animation(.spring(), value: configuration.isPressed) }

func getPadding() -> EdgeInsets {
    let unit:CGFloat = 4
    switch controlSize {
        case .regular:
            return EdgeInsets(top: unit * 2, leading: unit * 4, bottom: unit * 2, trailing: unit * 4)
        case .large:
            return EdgeInsets(top: unit * 3, leading: unit * 5, bottom: unit * 3, trailing: unit * 5)
        case .mini:
            return EdgeInsets(top: unit / 2, leading: unit * 2, bottom: unit/2, trailing: unit * 2)
        case .small:
            return EdgeInsets(top: unit, leading: unit * 3, bottom: unit, trailing: unit * 3)
        @unknown default:
            fatalError()
    }
}

func getFontSize() -> Font {
    switch controlSize {
        case .regular:
            return .body
        case .large:
            return .title3
        case .small:
            return .callout
        case .mini:
            return .caption2
        @unknown default:
            fatalError()
    }
}

}

extension ButtonStyle where Self == RoundedAndShadowProButtonStyle { static var roundedAndShadowPro:RoundedAndShadowProButtonStyle { RoundedAndShadowProButtonStyle() } }

// 使用 HStack { Button(role: .destructive, action: { pressAction("rounded and shadow pro") }, label: { label }) .buttonStyle(.roundedAndShadowPro) .controlSize(.large) Button(action: { pressAction("rounded and shadow pro") }, label: { label }) .buttonStyle(.roundedAndShadowPro) .controlSize(.small) } ```

image-20230215183940567

使用 PrimitiveButtonStyle 定製互動行為

在 SwiftUI 中,Button 預設的互動行為是在鬆開按鈕的同時執行 Button 指定的操作。並且,在點選按鈕後,只要手指( 滑鼠 )不鬆開,無論移動到哪裡( 移動到 Button 檢視之外 ),鬆開後仍會執行指定操作。

儘管 Button 的預設手勢與 TapGestur 單擊操作類似,~~但 Button 的手勢是一種不可撤銷的操作~~。而 TapGesture 在不鬆開手指的情況下,如果移動到可點選區域外,SwiftUI 將不會呼叫 onEnded 閉包中的操作。

經網友 @Yoo_Das 的反饋,上文中 “Button 的手勢是一種不可撤銷的操作” 的描述不夠準確。Button 的手勢可以被視為有條件的可撤銷操作。在按下按鈕後,當手指移動的距離超出了系統預設的距離餘量( 沒有明確值 )後再鬆開,按鈕閉包並不會被呼叫。

假如,我們想達成與 TapGesture 類似的效果( 可撤銷按鈕 ),則可以通過 SwiftUI 提供的另一個協議 PrimitiveButtonStyle 來實現。

```swift public protocol PrimitiveButtonStyle { @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body typealias Configuration = PrimitiveButtonStyleConfiguration }

public struct PrimitiveButtonStyleConfiguration { public let role: ButtonRole? public let label: PrimitiveButtonStyleConfiguration.Label public func trigger() } ```

PrimitiveButtonStyle 與 ButtonStyle 兩者之間最大的不同是,PrimitiveButtonStyle 要求開發者必須通過自行完成互動操作邏輯,並在適當的時機呼叫 trigger 方法( 可以理解為 Button 的 action 引數對應的閉包 )。

```swift struct CancellableButtonStyle:PrimitiveButtonStyle { @GestureState var isPressing = false

func makeBody(configuration: Configuration) -> some View {
    let drag = DragGesture(minimumDistance: 0)
        .updating($isPressing, body: {_,pressing,_ in
            if !pressing { pressing = true}
        })

    configuration.label
        .foregroundColor(.white)
        .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        .background(
            RoundedRectangle(cornerRadius: 10)
                .foregroundColor( configuration.role == .destructive ? .red : .blue)
        )
        .compositingGroup()
        .shadow(radius:isPressing ? 0 : 5,x:0,y: isPressing ? 0 :3)
        .scaleEffect(isPressing ? 0.95 : 1)
        .animation(.spring(), value: isPressing)
        // 獲取點選狀態
        .gesture(drag)
        .simultaneousGesture(TapGesture().onEnded{
            configuration.trigger() // 執行 Button 指定的操作
        })
}

}

extension PrimitiveButtonStyle where Self == CancellableButtonStyle { static var cancellable:CancellableButtonStyle { CancellableButtonStyle() } } ```

cancallableStyle_2023-02-15_19.06.47.2023-02-15 19_08_00

或許有人會說,既然上面的程式碼可以通過 DragGesture 模擬獲取到點選狀態,那麼完全可以不使用 PrimitiveButtonStyle 實現同樣的效果。如此一來使用 Style 的優勢在哪裡呢

  • ButtonStyle 和 PrimitiveButtonStyle 是專門針對按鈕的樣式 API ,它們不僅可以應用於 Button 檢視,也可以應用於很多 SwiftUI 預置的系統按鈕功能之上,例如:EditButton、Share、Link、NavigationLink( 不在 List 中) 等。
  • keyboardShortcut 修飾器也只能應用於 Button,檢視 + TapGesture 無法設定快捷鍵。

無論是雙擊、長按、甚至通過體感觸發,開發者均可以通過 PrimitiveButtonStyle 協議定製自己的按鈕互動邏輯。

系統預置的 Style

從 iOS 15 開始,SwiftUI 在原有 PlainButtonStyle、DefaultButtonStyle 的基礎上,提供了更加豐富的預置 Style。

  • PlainButtonStyle:不對 Button 檢視新增任何修飾
  • BorderlessButtonStyle:多數情況下的預設樣式,在未指定文字顏色的情況下,將文字修改為強調色
  • BorderedButtonStyle:為按鈕新增圓角矩形背景,使用 tint 顏色作為背景色
  • BorderedProminentButtonStyle:為按鈕新增圓角矩形背景,背景顏色為系統強調色

其中,PlainButtonStyle 除了可以應用於 Button 外,同時也會對 List 以及 Form 的單元格行為造成影響。預設情況下,即使單元格的檢視中包含了多個按鈕,SwiftUI 也只會將 List 的單元格視作一個按鈕( 點選後同時呼叫所有按鈕的操作 )。通過為 List 設定 PlainButtonStyle 風格,便可以調整這一行為,讓一個單元格中的多個按鈕可以被分別觸發。

swift List { HStack { Button("11"){print("1")} Button("22"){print("2")} } } .buttonStyle(.plain)

注意事項

  • 同 ViewModifier 不同,ButtonStyle 並不支援串聯,Button 只會採用最靠近的 Style

swift VStack { Button("11"){print("1")} // plain Button("22"){print("2")} // borderless .buttonStyle(.borderless) Button("33"){print("3")} // borderedProminent .buttonStyle(.borderedProminent) .buttonStyle(.borderless) } .buttonStyle(.plain)

  • 某些按鈕樣式在不同的上下文中的行為和外觀會有較大差別,甚至不起作用。例如:無法為 List 中的 NavigationLink 設定樣式
  • 在 Button 的 label 檢視或 ButtonStyle 實現中新增的手勢操作( 例如 TapGesture )將導致 Button 不再呼叫其指定的閉包操作,附加手勢需在 Button 之外新增( 例如下文的 simultaneousGesture 實現 )

為按鈕新增 Trigger

在 SwiftUI 中,為了判斷某個按鈕是否被按下( 尤其是系統按鈕 ),我們通常會通過設定並行手勢來新增 trigger :

swift EditButton() .buttonStyle(.roundedAndShadowPro) .simultaneousGesture(TapGesture().onEnded{ print("pressed")}) // 設定並行手勢 .withTitle("edit button with simultaneous trigger")

不過,上述方法在 macOS 下不起作用 。通過 Style ,我們可以在設定按鈕樣式時為其新增觸發器:

```swift struct TriggerActionStyle:ButtonStyle { let trigger:() -> Void init(trigger: @escaping () -> Void) { self.trigger = trigger } func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(.white) .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .background( RoundedRectangle(cornerRadius: 10) .foregroundColor(.blue) ) .compositingGroup() .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3) .scaleEffect(configuration.isPressed ? 0.95 : 1) .animation(.spring(), value: configuration.isPressed) .onChange(of: configuration.isPressed){ isPressed in if !isPressed { trigger() } } } }

extension ButtonStyle where Self == TriggerActionStyle { static func triggerAction(trigger perform:@escaping () -> Void) -> TriggerActionStyle { .init(trigger: perform) } } ```

trigger1_2023-02-15_20.08.05.2023-02-15 20_09_17

當然,用 PrimitiveButtonStyle 也一樣可以實現:

```swift struct TriggerButton2: PrimitiveButtonStyle { var trigger: () -> Void

func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
    MyButton(trigger: trigger, configuration: configuration)
}

struct MyButton: View {
    @State private var pressed = false
    var trigger: () -> Void

    let configuration: PrimitiveButtonStyle.Configuration

    var body: some View {
        return configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.blue)
            )
            .compositingGroup()
            .shadow(radius: pressed ? 0 : 5, x: 0, y: pressed ? 0 : 3)
            .scaleEffect(pressed ? 0.95 : 1)
            .animation(.spring(), value: pressed)
            .onLongPressGesture(minimumDuration: 2.5, maximumDistance: .infinity, pressing: { pressing in
                withAnimation(.easeInOut(duration: 0.3)) {
                    self.pressed = pressing
                }
                if pressing {
                    configuration.trigger() // 原來的 action
                    trigger() // 新增的 action
                } else {
                    print("release")
                }
            }, perform: {})
    }
}

} ```

trigger2_2023-02-15_20.15.56.2023-02-15 20_16_30

總結

儘管自定義 Style 的效果顯著,但遺憾的是,目前 SwiftUI 僅開放了少數的元件樣式協議供開發者自定義使用,並且提供的屬性也很有限。希望在未來的版本中,SwiftUI 可以為開發者提供更加強大的自定義元件能力。

希望本文能夠對你有所幫助。同時也歡迎你通過 TwitterDiscord 頻道 或部落格的留言板與我進行交流。

訂閱下方的 郵件列表,可以及時獲得每週的 Tips 彙總。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】