自定義 Button 的外觀和互動行為
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
let label = Label("Press Me", systemImage: "digitalcrown.horizontal.press.fill")
RoundedAndShadowButton(label: label, action: { pressAction("button view") }) ```
使用 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)
建立一個通用性好 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) } ```
使用 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() } } ```
或許有人會說,既然上面的程式碼可以通過 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) } } ```
當然,用 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: {})
}
}
} ```
總結
儘管自定義 Style 的效果顯著,但遺憾的是,目前 SwiftUI 僅開放了少數的元件樣式協議供開發者自定義使用,並且提供的屬性也很有限。希望在未來的版本中,SwiftUI 可以為開發者提供更加強大的自定義元件能力。
希望本文能夠對你有所幫助。同時也歡迎你通過 Twitter、 Discord 頻道 或部落格的留言板與我進行交流。
訂閱下方的 郵件列表,可以及時獲得每週的 Tips 彙總。
原文發表在我的部落格 wwww.fatbobman.com
歡迎訂閱我的公共號:【肘子的Swift記事本】
- 自定義 Button 的外觀和互動行為
- MacBook Pro 使用體驗
- 用 SwiftUI 的方式進行佈局
- 聊一聊可組裝框架( TCA )
- StateObject 與 ObservedObject
- 一些適合 SwiftUI 初學者的教程
- iBug 16 有感
- 在 SwiftUI 中實現檢視居中的若干種方法
- SwiftUI 佈局 —— 尺寸( 下 )
- SwiftUI 佈局 —— 尺寸( 上 )
- SwiftUI 佈局 —— 對齊
- Core Data with CloudKit(三)——CloudKit儀表臺
- Core Data with CloudKit(二)——同步本地資料庫到iCloud私有資料庫
- 在CoreData中使用持久化歷史跟蹤
- 用 Table 在 SwiftUI 下建立表格
- SwiftUI 4.0 的全新導航系統
- 如何在 Core Data 中進行批量操作
- Core Data 是如何在 SQLite 中儲存資料的
- 在 SwiftUI 檢視中開啟 URL 的若干方法
- 為自定義屬性包裝型別新增類 @Published 的能力