美文网首页
SwiftUI之Custom Styling

SwiftUI之Custom Styling

作者: 老马的春天 | 来源:发表于2020-06-21 17:51 被阅读0次

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

    什么是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协议:ButtonStylePrimitiveButtonStyle。后边的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<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,也就是环境变量的值必须是一个类型,如果我们自定义了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

          本文链接:https://www.haomeiwen.com/subject/rkmaxktx.html