本篇文章將會非常有趣,相信我,看完這篇文章一定會收獲滿滿。
什么是Style
相信大家在學(xué)習(xí)SwiftUI過程中,一定接觸了類似于ButonStyle,ToggleStyle這樣的東西。 拿Button來舉例,通過其.buttonStyle()modifier,我們可以修改按鈕的外在樣式,這說明,對于Button來老說,所謂的style就是指它的外在樣式。
與外在Style相對應(yīng)的則是某個可交互控件的內(nèi)部邏輯了。還是拿Button舉例,它內(nèi)在的邏輯就是可以處理點擊事件,不管其外在樣式如何變化,它內(nèi)在的這個邏輯不會變。
Toggle的內(nèi)在邏輯是可以在兩個狀態(tài)間進(jìn)行切換,而一般的外在樣式表現(xiàn)為一個開關(guān)的樣式。
總結(jié)一下,對于任何可交互的view來說,其有兩部分組成:
- 內(nèi)在的邏輯
- 外在的樣式
所謂的Style,就是根據(jù)內(nèi)在邏輯的狀態(tài),返回一個與之相對應(yīng)的外在樣式。
Style如何工作
ButtonStyle,ToggleStyle或者其他的styles,本質(zhì)上都是一個簡單的協(xié)議,該協(xié)議中只有一個方法,:
func makeBody(configuration: Self.Configuration) -> some View
我們再看一下該函數(shù)的參數(shù):
public struct ButtonStyleConfiguration {
public let label: ButtonStyleConfiguration.Label
public let isPressed: Bool
}
Button的configuration給我們返回了兩條有用的信息:
- label:按鈕的內(nèi)容
- isPressed: 按鈕當(dāng)前的按壓狀態(tài)
不難理解,makeBody的目的就是讓我們利用configuration提供的信息,返回一個相應(yīng)的view。
系統(tǒng)已經(jīng)為某些view提供了一些style,可以直接通過modifier進(jìn)行設(shè)置,本篇文章不討論這些style,我們直接進(jìn)入自定義style的世界。
Button Custom Styles
Button一共有兩個style協(xié)議:ButtonStyle和PrimitiveButtonStyle。后邊的style能夠提供更多的控制能力。
對于自定義ButtonStyle來說,實在是太簡單了,只需要根據(jù)不同的isPressed返回不同的樣式就可以了,也就是未按壓顯示一種樣式,按壓后顯示另一種樣式。

實現(xiàn)上圖中的按壓高亮效果的代碼如下:
struct MyButtonStyleExample: View {
var body: some View {
VStack {
Button("Tap Me!") {
print("button pressed!")
}.buttonStyle(MyButtonStyle(color: .blue))
}
}
}
struct MyButtonStyle: ButtonStyle {
var color: Color = .green
public func makeBody(configuration: MyButtonStyle.Configuration) -> some View {
configuration.label
.foregroundColor(.white)
.padding(15)
.background(RoundedRectangle(cornerRadius: 5).fill(color))
.compositingGroup()
.shadow(color: .black, radius: 3)
.opacity(configuration.isPressed ? 0.5 : 1.0)
.scaleEffect(configuration.isPressed ? 0.8 : 1.0)
}
}
PrimitiveButtonStyle可以讓我們控制按鈕事件觸發(fā)的時機(jī),在UIKit中,我們可以通過一個枚舉來設(shè)置按鈕點擊事件的觸發(fā)時機(jī),在SwiftUI中,Button并沒有直接的設(shè)置方法,因此,我們就可以通過自定義PrimitiveButtonStyle來實現(xiàn)這個功能。
大家看下邊這個點擊過程,當(dāng)我們長按按鈕超過1秒后,才會觸發(fā)按鈕的點擊事件,觸發(fā)后,會顯示上方的文字:

代碼如下:
struct ContentView: View {
@State private var text = ""
var body: some View {
VStack(spacing: 20) {
Text(text)
Button("Tap Me!") {
self.text = "Action Executed!"
}.buttonStyle(MyPrimitiveButtonStyle(color: .red))
}
}
}
struct MyPrimitiveButtonStyle: PrimitiveButtonStyle {
var color: Color
func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
MyButton(configuration: configuration, color: color)
}
struct MyButton: View {
@GestureState private var pressed = false
let configuration: PrimitiveButtonStyle.Configuration
let color: Color
var body: some View {
let longPress = LongPressGesture(minimumDuration: 1.0, maximumDistance: 0.0)
.updating($pressed) { value, state, _ in state = value }
.onEnded { _ in
self.configuration.trigger()
}
return configuration.label
.foregroundColor(.white)
.padding(15)
.background(RoundedRectangle(cornerRadius: 5).fill(color))
.compositingGroup()
.shadow(color: .black, radius: 3)
.opacity(pressed ? 0.5 : 1.0)
.scaleEffect(pressed ? 0.8 : 1.0)
.gesture(longPress)
}
}
}
Custom Toggle Style
自定義Toggle跟自定義button,沒有什么太大的區(qū)別,都是通過其狀態(tài)返回相對應(yīng)的樣式就ok了。在這一小節(jié),我們舉2個例子。
第一個例子是最簡單的,我們根據(jù)Toggle的狀態(tài)返回一個自定義的樣式,效果如下:

直接看代碼:
struct Example1: View {
@State private var flag = true
var body: some View {
VStack {
Toggle(isOn: $flag) {
HStack {
Image(systemName: "ARKit")
Text("是否開啟AR功能:")
}
}
}
.toggleStyle(MyToggleStyle1())
}
}
struct MyToggleStyle1: ToggleStyle {
let width: CGFloat = 50
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
RoundedRectangle(cornerRadius: 4)
.frame(width: width, height: width / 2.0)
.foregroundColor(configuration.isOn ? .green : .red)
RoundedRectangle(cornerRadius: 4)
.frame(width: (width / 2) - 4, height: (width / 2) - 6)
.padding(4)
.foregroundColor(.white)
.onTapGesture {
withAnimation {
configuration.$isOn.wrappedValue.toggle()
}
}
}
}
}
}
上邊這段代碼有2點值得重點關(guān)注的地方:
- 我給Toggle的label傳了一個
HStack,從顯示效果來看,說明這個label,可以是任何view,也就是some View -
.toggleStyle(MyToggleStyle1())這個modifier我寫在了VStack外邊,大家不覺得奇怪嗎?VStack里邊的Toggle竟然也接收到了參數(shù)。
這里關(guān)于第2點,先埋一個小伏筆,我們會在下邊介紹如何實現(xiàn)這項技術(shù)。
大家再看下邊這個效果:

- 點擊后,正向翻轉(zhuǎn)180度
- 再次點擊,反向翻轉(zhuǎn)180度,回到原始狀態(tài)
這個例子在平時開發(fā)中還是很常見的,當(dāng)翻轉(zhuǎn)到90度的時候,需要切換圖片和文字,實現(xiàn)該功能,用到的核心技術(shù)為GeometryEffect,我在SwiftUI動畫(2)之GeometryEffect這篇文章中已經(jīng)詳細(xì)講述了,大家有興趣可以去閱讀那篇文章。
代碼如下:
struct Example2: View {
@State private var flag = false
@State private var flipped = false
var body: some View {
VStack {
Toggle(isOn: $flag) {
VStack {
Group {
Image(systemName: flipped ? "folder.fill" : "map.fill")
Text(flipped ? "地圖" : "列表")
.font(.caption)
}
.rotation3DEffect(flipped ? .degrees(180) : .degrees(0), axis: (x: 0, y: 1, z: 0))
}
}
}
.toggleStyle(MyToggleStyle2(flipped: $flipped))
}
}
struct FlipEffect: GeometryEffect {
@Binding var flipped: Bool
var angle: Double
var animatableData: Double {
get {
angle
}
set {
angle = newValue
}
}
func effectValue(size: CGSize) -> ProjectionTransform {
DispatchQueue.main.async {
self.flipped = (self.angle >= 90 && self.angle <= 180)
}
let a = CGFloat(Angle.degrees(angle).radians)
var transform3d = CATransform3DIdentity
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, 0, 1, 0)
transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height/2.0))
return ProjectionTransform(transform3d).concatenating(affineTransform)
}
}
struct MyToggleStyle2: ToggleStyle {
let width: CGFloat = 50
let height: CGFloat = 60
@Binding var flipped: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: width, height: height)
.modifier(FlipEffect(flipped: $flipped, angle: configuration.isOn ? 180 : 0))
.onTapGesture {
withAnimation {
configuration.$isOn.wrappedValue.toggle()
}
}
}
}
GeometryEffect的本質(zhì)是:在動畫執(zhí)行時, 會不斷的調(diào)用effectValue函數(shù),我們可以在此函數(shù)中,根據(jù)當(dāng)前狀態(tài)返回對應(yīng)的形變信息即可。
上邊這個翻轉(zhuǎn)的例子,表面看上去不像是一個Toggle,但確實是通過自定義ToggleStyle實現(xiàn)的,其內(nèi)部的邏輯也是兩種狀態(tài)之間的切換,我們可以通過flag來監(jiān)聽到狀態(tài)的改變。
關(guān)于自定義Style,這里做一個簡單的補(bǔ)充,在iOS, macOS等不同的平臺中,可能會有不同的樣式問題,因此需要考慮多個平臺的適配問題,但在這里,就不做詳細(xì)的介紹了。
Styled Custom Views
這一小節(jié),是本篇文章的核心,學(xué)會后,我們就可以自定義任何含有內(nèi)在邏輯的交互控件了。先給大家看一下效果圖:

- 該控件的內(nèi)在邏輯有3種狀態(tài),分別為低,中, 高
- 提供了上述的3種不同的style,分別為DefaultTripleToggleStyle,KnobTripleToggleStyle和DashBoardTripleToggleStyle
代碼如下:
struct Example4: View {
@State var state: TripleState = .low
var stateDesc: String {
get {
switch self.state {
case .low:
return "低"
case .med:
return "中"
case .high:
return "高"
}
}
}
var body: some View {
VStack {
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
.tripleToggleStyle(KnobTripleToggleStyle(dotColor: .red))
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
.tripleToggleStyle(KnobTripleToggleStyle(dotColor: .black))
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
.frame(width: 300, height: 200)
.tripleToggleStyle(DashBoardTripleToggleStyle())
}
.tripleToggleStyle(DefaultTripleToggleStyle())
}
}
在自定義任何Style之前,我們一定要先分析該控件的內(nèi)在邏輯是什么?在本例中,其內(nèi)在邏輯是3種狀態(tài)的切換,因此我們首先就定義一個枚舉,用來表示這3種狀態(tài):
public enum TripleState: Int {
case low
case med
case high
}
在上邊的例子中,我們在寫makeBody函數(shù)的時候,需要拿到當(dāng)前的狀態(tài),這個狀態(tài)保存在Configuration中,也就是makeBody的入?yún)?,在本例中,我們的Configuration定義如下:
public struct TripleToggleStyleConfiguration {
@Binding var tripleState: TripleState
var label: Text
}
大家知道tripleState為什么要修飾成@Binding嗎?原因是,當(dāng)我們在用makeBody返回自定的view的時候,我們通常會給這個view添加點擊事件,點擊后,需要修改狀態(tài)。
接下來,我們把我們這個style命名為TripleToggleStyle,表示有3種狀態(tài)可以切換。在寫這個協(xié)議之前,我們先看看ButtonStyle協(xié)議是怎么寫的?
public protocol ButtonStyle {
associatedtype Body : View
func makeBody(configuration: Self.Configuration) -> Self.Body
typealias Configuration = ButtonStyleConfiguration
}
這個協(xié)議非常簡單啊 ,只有一個方法,我們模仿它寫一個TripleToggleStyle:
protocol TripleToggleStyle {
associatedtype Body: View
func makeBody(configuration: Self.Configuration) -> Self.Body
typealias Configuration = TripleToggleStyleConfiguration
}
大家發(fā)現(xiàn)沒有,幾乎一摸一樣,associatedtype表示關(guān)聯(lián)類型,在這里Body的約束條件是必須實現(xiàn)View協(xié)議。
到這里,我們還沒遇到什么難度,但是現(xiàn)在我們需要思考,如何實現(xiàn)類似于下邊這樣的效果:
var body: some View {
VStack {
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
.tripleToggleStyle(KnobTripleToggleStyle(dotColor: .red))
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
.tripleToggleStyle(KnobTripleToggleStyle(dotColor: .black))
TripleToggle(label: Text(self.stateDesc), tripleState: $state)
.frame(width: 300, height: 200)
.tripleToggleStyle(DashBoardTripleToggleStyle())
}
.tripleToggleStyle(DefaultTripleToggleStyle())
}
tripleToggleStyle不管是直接作用于TripleToggle還是作用于VStack,都需要有效果。那么該如何實現(xiàn)這一需求呢?
其實也很簡單,在SwiftUI中的環(huán)境變量天然有這樣的優(yōu)勢,子view會繼承父view的環(huán)境變量,因此,tripleToggleStyle()函數(shù)的主要作用就應(yīng)該是給view設(shè)置環(huán)境變量。
extension View {
func tripleToggleStyle<S>(_ style: S) -> some View where S: TripleToggleStyle {
self.environment(\.tripleToggleStyle, AnyTripleToggleStyle(style))
}
}
在上邊的代碼中,我們需要給environment新增一個新的計算屬性,名稱為tripleToggleStyle,其值為AnyTripleToggleStyle。
這里有一個問題需要思考,為什么我們需要AnyTripleToggleStyle呢? 我們看看AnyTripleToggleStyle的定義:
extension TripleToggleStyle {
func makeBodyTypeErased(configuration: Self.Configuration) -> AnyView {
AnyView(self.makeBody(configuration: configuration))
}
}
public struct AnyTripleToggleStyle: TripleToggleStyle {
private let _makeBody: (TripleToggleStyleConfiguration) -> AnyView
init<ST: TripleToggleStyle>(_ style: ST) {
self._makeBody = style.makeBodyTypeErased
}
func makeBody(configuration: Configuration) -> some View {
return self._makeBody(configuration)
}
}
從上邊的代碼可以看出,AnyTripleToggleStyle的主要目的是把makeBody返回的some View再包裝到AnyView中,這么多有什么好處呢?
因為EnvironmentValues,也就是環(huán)境變量的值必須是一個類型,如果我們自定義了AToggleStyle,BToggleStyle,CToggleStyle等多個style時,就有問題了,我們需要把這些自定義的類型再包裝一層,也就是AnyTripleToggleStyle。

這基本上是一個固定的套路 ,這些代碼完全可以復(fù)用。大家可以細(xì)品一下在前邊加Any前綴的妙處。
有了AnyTripleToggleStyle后,在寫環(huán)境變量的代碼就非常簡單了:
extension EnvironmentValues {
var tripleToggleStyle: AnyTripleToggleStyle {
get {
self[TripleToggleKey.self]
}
set {
self[TripleToggleKey.self] = newValue
}
}
}
public struct TripleToggleKey: EnvironmentKey {
public static var defaultValue: AnyTripleToggleStyle = AnyTripleToggleStyle(DefaultTripleToggleStyle())
}
EnvironmentKey協(xié)議要求必須返回一個默認(rèn)的值,我們返回一個就好了,在EnvironmentValues擴(kuò)展中的計算屬性tripleToggleStyle,就是我們?nèi)≈禃r需要用到的keypath名稱。
取環(huán)境變量的代碼如下:
@Environment(\.tripleToggleStyle) var style: AnyTripleToggleStyle
接下來,我們繼續(xù)寫一個自定義的view,用于接受上邊的這些信息,代碼跟Toggle的定義很像:
public struct TripleToggle: View {
@Environment(\.tripleToggleStyle) var style: AnyTripleToggleStyle
let label: Text
@Binding var tripleState: TripleState
public var body: some View {
let config = TripleToggleStyleConfiguration(tripleState: self.$tripleState, label: self.label)
return style.makeBody(configuration: config)
}
}
最后我們只要實現(xiàn)了TripleToggleStyle協(xié)議,就可以自定義任何樣式的style了,這里只提供了3種樣式:
DefaultTripleToggleStyle:
public struct DefaultTripleToggleStyle: TripleToggleStyle {
func makeBody(configuration: Configuration) -> some View {
DefaultTripleToggle(state: configuration.$tripleState, label: configuration.label)
}
struct DefaultTripleToggle: View {
let width: CGFloat = 60
@Binding var state: TripleState
var label: Text
var stateAlignment: Alignment {
switch self.state {
case .low:
return .leading
case .med:
return .center
case .high:
return .trailing
}
}
var stateColor: Color {
switch self.state {
case .low:
return .green
case .med:
return .yellow
case .high:
return .red
}
}
var body: some View {
VStack(spacing: 10) {
label
ZStack(alignment: self.stateAlignment) {
RoundedRectangle(cornerRadius: 4)
.frame(width: self.width, height: self.width / 2.0)
.foregroundColor(self.stateColor)
RoundedRectangle(cornerRadius: 4)
.frame(width: self.width / 2 - 4, height: self.width / 2 - 6)
.padding(4)
.foregroundColor(.white)
.onTapGesture {
withAnimation {
switch self.state {
case .low:
self.$state.wrappedValue = .med
case .med:
self.$state.wrappedValue = .high
case .high:
self.$state.wrappedValue = .low
}
}
}
}
}
}
}
}
KnobTripleToggleStyle:
public struct KnobTripleToggleStyle: TripleToggleStyle {
let dotColor: Color
func makeBody(configuration: Self.Configuration) -> KnobTripleToggleStyle.KnobTripleToggle {
KnobTripleToggle(dotColor: dotColor, state: configuration.$tripleState, label: configuration.label)
}
public struct KnobTripleToggle: View {
let dotColor: Color
@Binding var state: TripleState
var label: Text
var angle: Angle {
switch self.state {
case .low: return Angle(degrees: -30)
case .med: return Angle(degrees: 0)
case .high: return Angle(degrees: 30)
}
}
public var body: some View {
let g = Gradient(colors: [.white, .gray, .white, .gray, .white, .gray, .white])
let knobGradient = AngularGradient(gradient: g, center: .center)
return VStack(spacing: 10) {
label
ZStack {
Circle()
.fill(knobGradient)
DotShape()
.fill(self.dotColor)
.rotationEffect(self.angle)
}.frame(width: 150, height: 150)
.onTapGesture {
withAnimation {
switch self.state {
case .low:
self.$state.wrappedValue = .med
case .med:
self.$state.wrappedValue = .high
case .high:
self.$state.wrappedValue = .low
}
}
}
}
}
}
struct DotShape: Shape {
func path(in rect: CGRect) -> Path {
return Path(ellipseIn: CGRect(x: rect.width / 2 - 8, y: 8, width: 16, height: 16))
}
}
}
DashBoardTripleToggleStyle:
struct DashBoardTripleToggleStyle: TripleToggleStyle {
func makeBody(configuration: Configuration) -> some View {
DashBoardTripleToggle(state: configuration.$tripleState, label: configuration.label)
}
struct DashBoardTripleToggle: View {
@Binding var state: TripleState
var label: Text
var angle: Double {
switch self.state {
case .low:
return -30
case .med:
return 0
case .high:
return 30
}
}
var body: some View {
VStack {
label
ZStack {
DashBoardShape(angle: self.angle)
.stroke(Color.green, lineWidth: 3)
}
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.green, lineWidth: 3))
}
}
struct DashBoardShape: Shape {
var angle: Double
var animatableData: Double {
get {
angle
}
set {
angle = newValue
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let l = Double(rect.height * 0.8)
let r = Angle(degrees: angle).radians
let x = Double(rect.midX) + l * sin(r)
let y = Double(rect.height) - l * cos(r)
path.move(to: .init(x: rect.midX, y: rect.maxY))
path.addLine(to: .init(x: x, y: y))
return path
}
}
}
}
完整代碼可在此處下載https://gist.github.com/agelessman/f9293a6c8626c6333e8b251993a79fd1
總結(jié)
關(guān)于自定義Style只需記住2點:
- 明確其內(nèi)部邏輯
- 根據(jù)狀態(tài)返回相對應(yīng)的View
*注:上邊的內(nèi)容參考了網(wǎng)站https://swiftui-lab.com/custom-styling/,如有侵權(quán),立即刪除。