IOS開發(fā)自定義Button的外觀和交互行為示例詳解
正文
通過 Style 改變組件的外觀或行為是 SwiftUI 提供的一項非常強大的功能。本文將介紹如何通過創(chuàng)建符合 ButtonStyle 或 PrimitiveButtonStyle 協議的實現,自定義 Button 的外觀以及交互行為。
可在 此處 獲取本文的范例代碼
定制 Button 的外觀
按鈕是 UI 設計中經常會使用到的組件。相較于 UIKit ,SwiftUI 通過 Button 視圖,讓開發(fā)者以少量的代碼便可完成按鈕的創(chuàng)建工作。
Button(action: signIn) {
Text("Sign In")
}
多數情況下,開發(fā)者通過為 Button 的 label 參數提供不同的視圖來定制按鈕的外觀。
struct RoundedAndShadowButton<V>: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") })

使用 ButtonStyle 定制交互動畫
遺憾的是,上面的代碼無法修改按鈕在點擊后的按壓效果。幸好,SwiftUI 提供了 ButtonStyle 協議可以幫助我們定制交互動畫。
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 提供的信息,開發(fā)者只需實現 makeBody 方法,即可完成交互動畫的定制工作。
- label:目標按鈕的當前視圖,通常對應著 Button 視圖中的 label 參數內容
- role:iOS 15 后新增的參數,用于標識按鈕的角色( 取消或具備破壞性)
- isPressed:當前按鈕的按壓狀態(tài),該信息是多數人使用 ButtonStyle 的原動力
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 視圖
Button(action: { pressAction("rounded and shadow") }, label: { label })
.buttonStyle(.roundedAndShadow)

創(chuàng)建一個通用性好 ButtonStyle 實現需要考慮很多條件,例如:role、controlSize、動態(tài)字體尺寸、色彩模式等等方面。同 ViewModifier 一樣,可以通過環(huán)境值獲取更多信息:
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 默認的交互行為是在松開按鈕的同時執(zhí)行 Button 指定的操作。并且,在點擊按鈕后,只要手指( 鼠標 )不松開,無論移動到哪里( 移動到 Button 視圖之外 ),松開后仍會執(zhí)行指定操作。
盡管 Button 的默認手勢與 TapGestur 單擊操作類似,但 Button 的手勢是一種不可撤銷的操作。而 TapGesture 在不松開手指的情況下,如果移動到可點擊區(qū)域外,SwiftUI 將不會調用 onEnded 閉包中的操作。
假如,我們想達成與 TapGesture 類似的效果( 可撤銷按鈕 ),則可以通過 SwiftUI 提供的另一個協議 PrimitiveButtonStyle 來實現。
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 要求開發(fā)者必須通過自行完成交互操作邏輯,并在適當的時機調用 trigger 方法( 可以理解為 Button 的 action 參數對應的閉包 )。
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)
// 獲取點擊狀態(tài)
.gesture(drag)
.simultaneousGesture(TapGesture().onEnded{
configuration.trigger() // 執(zhí)行 Button 指定的操作
})
}
}
extension PrimitiveButtonStyle where Self == CancellableButtonStyle {
static var cancellable:CancellableButtonStyle {
CancellableButtonStyle()
}
}

或許有人會說,既然上面的代碼可以通過 DragGesture 模擬獲取到點擊狀態(tài),那么完全可以不使用 PrimitiveButtonStyle 實現同樣的效果。如此一來使用 Style 的優(yōu)勢在哪里呢?
- ButtonStyle 和 PrimitiveButtonStyle 是專門針對按鈕的樣式 API ,它們不僅可以應用于 Button 視圖,也可以應用于很多 SwiftUI 預置的系統(tǒng)按鈕功能之上,例如:EditButton、Share、Link、NavigationLink( 不在 List 中) 等。
keyboardShortcut修飾器也只能應用于 Button,視圖 + TapGesture 無法設定快捷鍵。
無論是雙擊、長按、甚至通過體感觸發(fā),開發(fā)者均可以通過 PrimitiveButtonStyle 協議定制自己的按鈕交互邏輯。
系統(tǒng)預置的 Style
從 iOS 15 開始,SwiftUI 在原有 PlainButtonStyle、DefaultButtonStyle 的基礎上,提供了更加豐富的預置 Style。
- PlainButtonStyle:不對 Button 視圖添加任何修飾
- BorderlessButtonStyle:多數情況下的默認樣式,在未指定文字顏色的情況下,將文字修改為強調色
- BorderedButtonStyle:為按鈕添加圓角矩形背景,使用 tint 顏色作為背景色
- BorderedProminentButtonStyle:為按鈕添加圓角矩形背景,背景顏色為系統(tǒng)強調色
其中,PlainButtonStyle 除了可以應用于 Button 外,同時也會對 List 以及 Form 的單元格行為造成影響。默認情況下,即使單元格的視圖中包含了多個按鈕,SwiftUI 也只會將 List 的單元格視作一個按鈕( 點擊后同時調用所有按鈕的操作 )。通過為 List 設置 PlainButtonStyle 風格,便可以調整這一行為,讓一個單元格中的多個按鈕可以被分別觸發(fā)。
List {
HStack {
Button("11"){print("1")}
Button("22"){print("2")}
}
}
.buttonStyle(.plain)
注意事項
- 同 ViewModifier 不同,ButtonStyle 并不支持串聯,Button 只會采用最靠近的 Style
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 中,為了判斷某個按鈕是否被按下( 尤其是系統(tǒng)按鈕 ),我們通常會通過設置并行手勢來添加 trigger :
EditButton()
.buttonStyle(.roundedAndShadowPro)
.simultaneousGesture(TapGesture().onEnded{ print("pressed")}) // 設置并行手勢
.withTitle("edit button with simultaneous trigger")
不過,上述方法在 macOS 下不起作用 。通過 Style ,我們可以在設置按鈕樣式時為其添加觸發(fā)器:
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 也一樣可以實現:
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 僅開放了少數的組件樣式協議供開發(fā)者自定義使用,并且提供的屬性也很有限。希望在未來的版本中,SwiftUI 可以為開發(fā)者提供更加強大的自定義組件能力。
以上就是IOS開發(fā)自定義Button的外觀和交互行為示例詳解的詳細內容,更多關于IOS自定義Button外觀交互的資料請關注腳本之家其它相關文章!
相關文章
iOS報Multiple?commands?produceMultiple錯誤的解決方案
這篇文章主要為大家介紹了iOS報Multiple?commands?produceMultiple錯誤的解決方案,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-11-11
在uiview 的tableView中點擊cell進入跳轉到另一個界面的實現方法
這篇文章主要介紹了在uiview 的tableView中點擊cell進入跳轉到另一個界面的實現方法,首先重寫uiviewcontrol方法,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2016-10-10
iOS App開發(fā)中Masonry布局框架的基本用法解析
這篇文章主要介紹了iOS App開發(fā)中Masonry布局框架的基本用法解析,Masonry支持iOS和OSX的Auto Layout,在GitHub上的人氣很高,需要的朋友可以參考下2016-03-03

