SwiftUI之Custom Styling

本篇文章將會非常有趣,相信我,看完這篇文章一定會收獲滿滿。

什么是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é)議:ButtonStylePrimitiveButtonStyle。后邊的style能夠提供更多的控制能力。

對于自定義ButtonStyle來說,實在是太簡單了,只需要根據(jù)不同的isPressed返回不同的樣式就可以了,也就是未按壓顯示一種樣式,按壓后顯示另一種樣式。

buttonstyle.gif

實現(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ā)后,會顯示上方的文字:

primitivebuttonstyle.gif

代碼如下:

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)返回一個自定義的樣式,效果如下:

Kapture 2020-06-21 at 16.20.50.gif

直接看代碼:

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ù)。

大家再看下邊這個效果:

Kapture 2020-06-21 at 16.30.40.gif
  • 點擊后,正向翻轉(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)在邏輯的交互控件了。先給大家看一下效果圖:

Kapture 2020-06-21 at 16.55.19.gif
  • 該控件的內(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。

Untitled Diagram-3.png

這基本上是一個固定的套路 ,這些代碼完全可以復(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),立即刪除。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容