IOS開(kāi)發(fā)自定義Button的外觀和交互行為示例詳解
正文
通過(guò) Style 改變組件的外觀或行為是 SwiftUI 提供的一項(xiàng)非常強(qiáng)大的功能。本文將介紹如何通過(guò)創(chuàng)建符合 ButtonStyle 或 PrimitiveButtonStyle 協(xié)議的實(shí)現(xiàn),自定義 Button 的外觀以及交互行為。
可在 此處 獲取本文的范例代碼
定制 Button 的外觀
按鈕是 UI 設(shè)計(jì)中經(jīng)常會(huì)使用到的組件。相較于 UIKit ,SwiftUI 通過(guò) Button 視圖,讓開(kāi)發(fā)者以少量的代碼便可完成按鈕的創(chuàng)建工作。
Button(action: signIn) {
Text("Sign In")
}
多數(shù)情況下,開(kāi)發(fā)者通過(guò)為 Button 的 label 參數(shù)提供不同的視圖來(lái)定制按鈕的外觀。
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 定制交互動(dòng)畫(huà)
遺憾的是,上面的代碼無(wú)法修改按鈕在點(diǎn)擊后的按壓效果。幸好,SwiftUI 提供了 ButtonStyle 協(xié)議可以幫助我們定制交互動(dòng)畫(huà)。
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 協(xié)議的使用方式與 ViewModifier 十分類(lèi)似。通過(guò) ButtonStyleConfiguration 提供的信息,開(kāi)發(fā)者只需實(shí)現(xiàn) makeBody 方法,即可完成交互動(dòng)畫(huà)的定制工作。
- label:目標(biāo)按鈕的當(dāng)前視圖,通常對(duì)應(yīng)著 Button 視圖中的 label 參數(shù)內(nèi)容
- role:iOS 15 后新增的參數(shù),用于標(biāo)識(shí)按鈕的角色( 取消或具備破壞性)
- isPressed:當(dāng)前按鈕的按壓狀態(tài),該信息是多數(shù)人使用 ButtonStyle 的原動(dòng)力
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()
// 根據(jù) isPressing 來(lái)調(diào)整交互動(dòng)畫(huà)
.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()
}
}
通過(guò) buttonStyle 修飾器應(yīng)用于 Button 視圖
Button(action: { pressAction("rounded and shadow") }, label: { label })
.buttonStyle(.roundedAndShadow)

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

或許有人會(huì)說(shuō),既然上面的代碼可以通過(guò) DragGesture 模擬獲取到點(diǎn)擊狀態(tài),那么完全可以不使用 PrimitiveButtonStyle 實(shí)現(xiàn)同樣的效果。如此一來(lái)使用 Style 的優(yōu)勢(shì)在哪里呢?
- ButtonStyle 和 PrimitiveButtonStyle 是專門(mén)針對(duì)按鈕的樣式 API ,它們不僅可以應(yīng)用于 Button 視圖,也可以應(yīng)用于很多 SwiftUI 預(yù)置的系統(tǒng)按鈕功能之上,例如:EditButton、Share、Link、NavigationLink( 不在 List 中) 等。
keyboardShortcut修飾器也只能應(yīng)用于 Button,視圖 + TapGesture 無(wú)法設(shè)定快捷鍵。
無(wú)論是雙擊、長(zhǎng)按、甚至通過(guò)體感觸發(fā),開(kāi)發(fā)者均可以通過(guò) PrimitiveButtonStyle 協(xié)議定制自己的按鈕交互邏輯。
系統(tǒng)預(yù)置的 Style
從 iOS 15 開(kāi)始,SwiftUI 在原有 PlainButtonStyle、DefaultButtonStyle 的基礎(chǔ)上,提供了更加豐富的預(yù)置 Style。
- PlainButtonStyle:不對(duì) Button 視圖添加任何修飾
- BorderlessButtonStyle:多數(shù)情況下的默認(rèn)樣式,在未指定文字顏色的情況下,將文字修改為強(qiáng)調(diào)色
- BorderedButtonStyle:為按鈕添加圓角矩形背景,使用 tint 顏色作為背景色
- BorderedProminentButtonStyle:為按鈕添加圓角矩形背景,背景顏色為系統(tǒng)強(qiáng)調(diào)色
其中,PlainButtonStyle 除了可以應(yīng)用于 Button 外,同時(shí)也會(huì)對(duì) List 以及 Form 的單元格行為造成影響。默認(rèn)情況下,即使單元格的視圖中包含了多個(gè)按鈕,SwiftUI 也只會(huì)將 List 的單元格視作一個(gè)按鈕( 點(diǎn)擊后同時(shí)調(diào)用所有按鈕的操作 )。通過(guò)為 List 設(shè)置 PlainButtonStyle 風(fēng)格,便可以調(diào)整這一行為,讓一個(gè)單元格中的多個(gè)按鈕可以被分別觸發(fā)。
List {
HStack {
Button("11"){print("1")}
Button("22"){print("2")}
}
}
.buttonStyle(.plain)
注意事項(xiàng)
- 同 ViewModifier 不同,ButtonStyle 并不支持串聯(lián),Button 只會(huì)采用最靠近的 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)
- 某些按鈕樣式在不同的上下文中的行為和外觀會(huì)有較大差別,甚至不起作用。例如:無(wú)法為 List 中的 NavigationLink 設(shè)置樣式
- 在 Button 的 label 視圖或 ButtonStyle 實(shí)現(xiàn)中添加的手勢(shì)操作( 例如 TapGesture )將導(dǎo)致 Button 不再調(diào)用其指定的閉包操作,附加手勢(shì)需在 Button 之外添加( 例如下文的 simultaneousGesture 實(shí)現(xiàn) )
為按鈕添加 Trigger
在 SwiftUI 中,為了判斷某個(gè)按鈕是否被按下( 尤其是系統(tǒng)按鈕 ),我們通常會(huì)通過(guò)設(shè)置并行手勢(shì)來(lái)添加 trigger :
EditButton()
.buttonStyle(.roundedAndShadowPro)
.simultaneousGesture(TapGesture().onEnded{ print("pressed")}) // 設(shè)置并行手勢(shì)
.withTitle("edit button with simultaneous trigger")
不過(guò),上述方法在 macOS 下不起作用 。通過(guò) Style ,我們可以在設(shè)置按鈕樣式時(shí)為其添加觸發(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)
}
}

當(dāng)然,用 PrimitiveButtonStyle 也一樣可以實(shí)現(xiàn):
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() // 原來(lái)的 action
trigger() // 新增的 action
} else {
print("release")
}
}, perform: {})
}
}
}

總結(jié)
盡管自定義 Style 的效果顯著,但遺憾的是,目前 SwiftUI 僅開(kāi)放了少數(shù)的組件樣式協(xié)議供開(kāi)發(fā)者自定義使用,并且提供的屬性也很有限。希望在未來(lái)的版本中,SwiftUI 可以為開(kāi)發(fā)者提供更加強(qiáng)大的自定義組件能力。
以上就是IOS開(kāi)發(fā)自定義Button的外觀和交互行為示例詳解的詳細(xì)內(nèi)容,更多關(guān)于IOS自定義Button外觀交互的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Objective-C的入門(mén)學(xué)習(xí)筆記
Objective-C過(guò)去一直以來(lái)都是iOS應(yīng)用程序開(kāi)發(fā)的主要支持語(yǔ)言,雖然現(xiàn)在有了Swift,但需要調(diào)用的很多現(xiàn)有類(lèi)庫(kù)還是Objective-C寫(xiě)成的,值得學(xué)習(xí),下面一起來(lái)看一下這份粗淺的Objective-C的入門(mén)學(xué)習(xí)筆記:2016-05-05
iOS中UITableview錯(cuò)位的問(wèn)題怎么修復(fù)
這篇文章主要介紹了iOS中UITableview錯(cuò)位的問(wèn)題以及修復(fù)方法,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友參考下吧2017-01-01
iOS仿小紅書(shū)呼吸燈動(dòng)畫(huà)(核心動(dòng)畫(huà)和定時(shí)器)兩種方式實(shí)現(xiàn)
本篇文章主要介紹了iOS仿小紅書(shū)呼吸燈動(dòng)畫(huà)(核心動(dòng)畫(huà)和定時(shí)器)兩種方式實(shí)現(xiàn),非常具有實(shí)用價(jià)值,需要的朋友可以參考下2017-04-04
iOS報(bào)Multiple?commands?produceMultiple錯(cuò)誤的解決方案
這篇文章主要為大家介紹了iOS報(bào)Multiple?commands?produceMultiple錯(cuò)誤的解決方案,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11
iOS搭建簡(jiǎn)易購(gòu)物車(chē)頁(yè)面
這篇文章主要為大家詳細(xì)介紹了iOS搭建簡(jiǎn)易購(gòu)物車(chē)頁(yè)面,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-08-08
在uiview 的tableView中點(diǎn)擊cell進(jìn)入跳轉(zhuǎn)到另一個(gè)界面的實(shí)現(xiàn)方法
這篇文章主要介紹了在uiview 的tableView中點(diǎn)擊cell進(jìn)入跳轉(zhuǎn)到另一個(gè)界面的實(shí)現(xiàn)方法,首先重寫(xiě)uiviewcontrol方法,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2016-10-10
iOS App開(kāi)發(fā)中Masonry布局框架的基本用法解析
這篇文章主要介紹了iOS App開(kāi)發(fā)中Masonry布局框架的基本用法解析,Masonry支持iOS和OSX的Auto Layout,在GitHub上的人氣很高,需要的朋友可以參考下2016-03-03
iOS頁(yè)面跳轉(zhuǎn)及數(shù)據(jù)傳遞(三種)
本文主要介紹了iOS頁(yè)面跳轉(zhuǎn)的三種方法及數(shù)據(jù)傳遞的方法。具有很好的參考價(jià)值。下面跟著小編一起來(lái)看下吧2017-03-03

