SwiftUI之Custom Styling

本篇文章将会非常有趣,相信我,看完这篇文章一定会收获满满。

什么是Style

相信大家在学习SwiftUI过程中,一定接触了类似于ButonStyleToggleStyle这样的东西。 拿Button来举例,通过其.buttonStyle()modifier,我们可以修改按钮的外在样式,这说明,对于Button来老说,所谓的style就是指它的外在样式。

与外在Style相对应的则是某个可交互控件的内部逻辑了。还是拿Button举例,它内在的逻辑就是可以处理点击事件,不管其外在样式如何变化,它内在的这个逻辑不会变。

Toggle的内在逻辑是可以在两个状态间进行切换,而一般的外在样式表现为一个开关的样式。

总结一下,对于任何可交互的view来说,其有两部分组成:

  • 内在的逻辑
  • 外在的样式

所谓的Style,就是根据内在逻辑的状态,返回一个与之相对应的外在样式。

Style如何工作

ButtonStyle,ToggleStyle或者其他的styles,本质上都是一个简单的协议,该协议中只有一个方法,:

func makeBody(configuration: Self.Configuration) -> some View

我们再看一下该函数的参数:

public struct ButtonStyleConfiguration {
    public let label: ButtonStyleConfiguration.Label
    public let isPressed: Bool
}

Button的configuration给我们返回了两条有用的信息:

  • label:按钮的内容
  • isPressed: 按钮当前的按压状态

不难理解,makeBody的目的就是让我们利用configuration提供的信息,返回一个相应的view。

系统已经为某些view提供了一些style,可以直接通过modifier进行设置,本篇文章不讨论这些style,我们直接进入自定义style的世界。

Button Custom Styles

Button一共有两个style协议:ButtonStyle和PrimitiveButtonStyle。后边的style能够提供更多的控制能力。

对于自定义ButtonStyle来说,实在是太简单了,只需要根据不同的isPressed返回不同的样式就可以了,也就是未按压显示一种样式,按压后显示另一种样式。

buttonstyle.gif

实现上图中的按压高亮效果的代码如下:

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可以让我们控制按钮事件触发的时机,在UIKit中,我们可以通过一个枚举来设置按钮点击事件的触发时机,在SwiftUI中,Button并没有直接的设置方法,因此,我们就可以通过自定义PrimitiveButtonStyle来实现这个功能。

大家看下边这个点击过程,当我们长按按钮超过1秒后,才会触发按钮的点击事件,触发后,会显示上方的文字:

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,没有什么太大的区别,都是通过其状态返回相对应的样式就ok了。在这一小节,我们举2个例子。

第一个例子是最简单的,我们根据Toggle的状态返回一个自定义的样式,效果如下:

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点值得重点关注的地方:

  • 我给Toggle的label传了一个HStack,从显示效果来看,说明这个label,可以是任何view,也就是some View
  • .toggleStyle(MyToggleStyle1())这个modifier我写在了VStack外边,大家不觉得奇怪吗?VStack里边的Toggle竟然也接收到了参数。

这里关于第2点,先埋一个小伏笔,我们会在下边介绍如何实现这项技术。

大家再看下边这个效果:

Kapture 2020-06-21 at 16.30.40.gif
  • 点击后,正向翻转180度
  • 再次点击,反向翻转180度,回到原始状态

这个例子在平时开发中还是很常见的,当翻转到90度的时候,需要切换图片和文字,实现该功能,用到的核心技术为GeometryEffect,我在SwiftUI动画(2)之GeometryEffect这篇文章中已经详细讲述了,大家有兴趣可以去阅读那篇文章。

代码如下:

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的本质是:在动画执行时, 会不断的调用effectValue函数,我们可以在此函数中,根据当前状态返回对应的形变信息即可。

上边这个翻转的例子,表面看上去不像是一个Toggle,但确实是通过自定义ToggleStyle实现的,其内部的逻辑也是两种状态之间的切换,我们可以通过flag来监听到状态的改变。

关于自定义Style,这里做一个简单的补充,在iOS, macOS等不同的平台中,可能会有不同的样式问题,因此需要考虑多个平台的适配问题,但在这里,就不做详细的介绍了。

Styled Custom Views

这一小节,是本篇文章的核心,学会后,我们就可以自定义任何含有内在逻辑的交互控件了。先给大家看一下效果图:

Kapture 2020-06-21 at 16.55.19.gif
  • 该控件的内在逻辑有3种状态,分别为低,中, 高
  • 提供了上述的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之前,我们一定要先分析该控件的内在逻辑是什么?在本例中,其内在逻辑是3种状态的切换,因此我们首先就定义一个枚举,用来表示这3种状态:

public enum TripleState: Int {
    case low
    case med
    case high
}

在上边的例子中,我们在写makeBody函数的时候,需要拿到当前的状态,这个状态保存在Configuration中,也就是makeBody的入参,在本例中,我们的Configuration定义如下:

public struct TripleToggleStyleConfiguration {
    @Binding var tripleState: TripleState
    var label: Text
}

大家知道tripleState为什么要修饰成@Binding吗?原因是,当我们在用makeBody返回自定的view的时候,我们通常会给这个view添加点击事件,点击后,需要修改状态。

接下来,我们把我们这个style命名为TripleToggleStyle,表示有3种状态可以切换。在写这个协议之前,我们先看看ButtonStyle协议是怎么写的?

public protocol ButtonStyle {

    associatedtype Body : View

    func makeBody(configuration: Self.Configuration) -> Self.Body

    typealias Configuration = ButtonStyleConfiguration
}

这个协议非常简单啊 ,只有一个方法,我们模仿它写一个TripleToggleStyle:

protocol TripleToggleStyle {
    associatedtype Body: View

    func makeBody(configuration: Self.Configuration) -> Self.Body

    typealias Configuration = TripleToggleStyleConfiguration
}

大家发现没有,几乎一摸一样,associatedtype表示关联类型,在这里Body的约束条件是必须实现View协议。

到这里,我们还没遇到什么难度,但是现在我们需要思考,如何实现类似于下边这样的效果:

    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,都需要有效果。那么该如何实现这一需求呢?

其实也很简单,在SwiftUI中的环境变量天然有这样的优势,子view会继承父view的环境变量,因此,tripleToggleStyle()函数的主要作用就应该是给view设置环境变量。

extension View {
    func tripleToggleStyle(_ 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(_ style: ST) {
        self._makeBody = style.makeBodyTypeErased
    }
    
    func makeBody(configuration: Configuration) -> some View {
        return self._makeBody(configuration)
    }
}

从上边的代码可以看出,AnyTripleToggleStyle的主要目的是把makeBody返回的some View再包装到AnyView中,这么多有什么好处呢?

因为EnvironmentValues,也就是环境变量的值必须是一个类型,如果我们自定义了AToggleStyle,BToggleStyle,CToggleStyle等多个style时,就有问题了,我们需要把这些自定义的类型再包装一层,也就是AnyTripleToggleStyle。

Untitled Diagram-3.png

这基本上是一个固定的套路 ,这些代码完全可以复用。大家可以细品一下在前边加Any前缀的妙处。

有了AnyTripleToggleStyle后,在写环境变量的代码就非常简单了:

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协议要求必须返回一个默认的值,我们返回一个就好了,在EnvironmentValues扩展中的计算属性tripleToggleStyle,就是我们取值时需要用到的keypath名称。

取环境变量的代码如下:

@Environment(\.tripleToggleStyle) var style: AnyTripleToggleStyle

接下来,我们继续写一个自定义的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)
    }
}

最后我们只要实现了TripleToggleStyle协议,就可以自定义任何样式的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

总结

关于自定义Style只需记住2点:

  • 明确其内部逻辑
  • 根据状态返回相对应的View

*注:上边的内容参考了网站https://swiftui-lab.com/custom-styling/,如有侵权,立即删除。

你可能感兴趣的:(SwiftUI之Custom Styling)