SwiftUI 样式开发

2,713 阅读4分钟

一、字节 DanceUI 中的 Styling 机制

字节的沙龙中提到 Styling 机制的用途:

  1. 使视图能以最适合当前上下文的方式显示
  2. 使视图样式与行为逻辑解耦分离
  3. 使不同的视图样式在各个业务场景中复用

代码示例:

import DanceUI

struct ContentView: View {
    @ObservedObject var viewModel: ViewModel
    var body: some View {
        CollectionView(dataCollection: viewModel.items) { item in 
            Cell(item: item)
        }
        .collectionViewStyle(.list()) // 列表样式
        // .collectionViewStyle(.twoColumn()) // 两列
        // .collectionViewStyle(.waterFlow()) // 瀑布流
    }
}

二、SwiftUI 的 Styling 方式

在 SwiftUI 的日常开发中我们可以看到:SwiftUI 也提供了通过 Styling 方式实现组建自定义功能。

例如以下修饰符: .buttonStyle(.bordered):对应 Button 控件的样式选择; .toggleStyle(.switch):对应 Toggle 控件的样式选择; .listStyle(.plain):对应 List 控件的样式选择; ...

使用 SwiftUI 现有的 Styling 方式,自定义一个新的样式只需要两个步骤:

  1. 自定义一个 Style,遵循某个 Style 协议,实现 makeBody 方法来做自定义操作;
  2. 扩展该协议,添加该 Style 类型。
struct CancellableButtonStyle: PrimitiveButtonStyle {
    @GestureState var isPressing = false
    func makeBody(configuration: Configuration) -> some View {
        ...
    }
}

extension PrimitiveButtonStyle where Self == CancellableButtonStyle {
    static var cancellable: CancellableButtonStyle {
        CancellableButtonStyle()
    }
}

// 使用时和系统已有的样式一样方便
Button {...}
    .buttonStyle(.cancellable)

三、实现样式开发1:仿系统

仿照系统,来自定义一个 View,且给它添加 Styling 的扩展方式:

  1. 定义 MySlider View;
  2. 定义 MySliderStyleConfiguration:用于传参配置;
  3. 扩展 MySlider,使用 MySliderStyleConfiguration 作为 MySlider init 方法;
  4. 定义 MySliderStyle 协议,包含 makeBody(configuration: Configuration) 方法;
  5. 扩展 MySliderStyle 协议,添加 resolve(configuration: Configuration) 方法,实现为调用 style.makeBody 方法;
  6. 基于 5,MySlider 中的 body 实现为把所有传参给 Configuration,然后调用 resolve(configuration: Configuration) ,即调用了 style.makeBody 方法,实现了类似系统的自定义 style 且实现 makeBody 即可扩展样式的功能。(但是这里通过 5 中转,才使得 UI 可交互,具体不明白,参考文章中有详细步骤);

----- MySlider 中的 style 属性如何获取?

  1. 定义一个 EnvironmentKey、扩展 EnvironmentValues、扩展 View 写入该环境值;
  2. 因此,MySlider 中的 style 属性通过环境值获取,使用的地方通过扩展 View 的修饰符写入;

----- 使用(同系统方式):

  1. 自定义一个 Style,遵循某个 Style 协议,实现 makeBody 方法来做自定义操作;
  2. 扩展该协议,添加该 Style 类型。
import SwiftUI

struct MySlider<Label: View, ValueLabel: View>: View {
    @Binding var value: Double
    var bounds: ClosedRange<Double>
    var label: Label
    var minimumValueLabel: ValueLabel
    var maximumValueLabel: ValueLabel
    var onEditingChanged: (Bool) -> Void
    
    @Environment(\.mySliderStyle) var style
    
    init(value: Binding<Double>,
         in bounds: ClosedRange<Double> = 0...1,
         @ViewBuilder label: () -> Label,
         minimumValueLabel: () -> ValueLabel,
         maximumValueLabel: () -> ValueLabel,
         onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
        self._value = value
        self.bounds = bounds
        self.label = label()
        self.minimumValueLabel = minimumValueLabel()
        self.maximumValueLabel = maximumValueLabel()
        self.onEditingChanged = onEditingChanged
    }
    
    var body: some View {
        let configuration = MySliderStyleConfiguration(
            value: $value,
            bounds: bounds,
            label: label,
            minimumValueLabel: minimumValueLabel,
            maximumValueLabel: maximumValueLabel,
            onEditingChanged: onEditingChanged)

        AnyView(style.resolve(configuration: configuration))
            .accessibilityElement(children: .combine)
            .accessibilityValue(valueText)
            .accessibilityAdjustableAction { direction in
                let boundsLength = bounds.upperBound - bounds.lowerBound
                let step = boundsLength / 10
                switch direction {
                case .increment:
                    value = max(value + step, bounds.lowerBound)
                case .decrement:
                    value = max(value - step, bounds.lowerBound)
                @unknown default:
                    break
                }
            }
    }
    
    var valueText: Text {
        if bounds == 0.0...1.0 {
            return Text(value, format: .percent)
        } else {
            return Text(value, format: .number)
        }
    }
}

// MARK: - Style Configuration Initializer

extension MySlider where Label == MySliderStyleConfiguration.Label, ValueLabel == MySliderStyleConfiguration.ValueLabel {
    init(_ configuration: MySliderStyleConfiguration) {
        self._value = configuration.$value
        self.bounds = configuration.bounds
        self.label = configuration.label
        self.minimumValueLabel = configuration.minimumValueLabel
        self.maximumValueLabel = configuration.maximumValueLabel
        self.onEditingChanged = configuration.onEditingChanged
    }
}

// MARK: - Style Configuration

struct MySliderStyleConfiguration {
    struct Label: View {
        let underlyingLabel: AnyView
        init(_ label: some View) {
            self.underlyingLabel = AnyView(label)
        }
        var body: some View {
            underlyingLabel
        }
    }
    
    struct ValueLabel: View {
        let underlyingLabel: AnyView
        init(_ label: some View) {
            self.underlyingLabel = AnyView(label)
        }
        var body: some View {
            underlyingLabel
        }
    }
    
    @Binding var value: Double
    let bounds: ClosedRange<Double>
    let label: Label
    let minimumValueLabel: ValueLabel
    let maximumValueLabel: ValueLabel
    let onEditingChanged: (Bool) -> Void
    
    init<Label: View, ValueLabel: View>(
        value: Binding<Double>,
        bounds: ClosedRange<Double>,
        label: Label,
        minimumValueLabel: ValueLabel,
        maximumValueLabel: ValueLabel,
        onEditingChanged: @escaping (Bool) -> Void) {
            self._value = value
        self.bounds = bounds
        self.label = label as? MySliderStyleConfiguration.Label ?? .init(label)
        self.minimumValueLabel = minimumValueLabel as? MySliderStyleConfiguration.ValueLabel ?? .init(minimumValueLabel)
        self.maximumValueLabel = maximumValueLabel as? MySliderStyleConfiguration.ValueLabel ?? .init(maximumValueLabel)
        self.onEditingChanged = onEditingChanged
    }
}

// MARK: - Style Protocol

protocol MySliderStyle: DynamicProperty {
    associatedtype Body: View
    
    @ViewBuilder func makeBody(configuration: Configuration) -> Body
    
    typealias Configuration = MySliderStyleConfiguration
}

// MARK: - Resolved Style

extension MySliderStyle {
    func resolve(configuration: Configuration) -> some View {
        ResolvedMySliderStyle(configuration: configuration, style: self)
    }
}

struct ResolvedMySliderStyle<Style: MySliderStyle>: View {
    var configuration: Style.Configuration
    var style: Style
    
    var body: Style.Body {
        style.makeBody(configuration: configuration)
    }
}

// MARK: - Environment

struct MySliderStyleKey: EnvironmentKey {
    static var defaultValue: any MySliderStyle = DefaultMySliderStyle()
}

extension EnvironmentValues {
    var mySliderStyle: any MySliderStyle {
        get { self[MySliderStyleKey.self] }
        set { self[MySliderStyleKey.self] = newValue }
    }
}

extension View {
    func mySliderStyle(_ style: some MySliderStyle) -> some View {
        environment(\.mySliderStyle, style)
    }
}

// MARK: - Default Style

struct DefaultMySliderStyle: MySliderStyle {
    func makeBody(configuration: Configuration) -> some View {
        Slider(value: configuration.$value,
               in: configuration.bounds,
               label: { configuration.label },
               minimumValueLabel: { configuration.minimumValueLabel },
               maximumValueLabel: { configuration.maximumValueLabel },
               onEditingChanged: configuration.onEditingChanged)
    }
}

extension MySliderStyle where Self == DefaultMySliderStyle {
    static var automatic: Self { .init() }
}

// MARK: - Custom Style

struct CustomMySliderStyle: MySliderStyle {
    @Environment(\.isEnabled) var isEnabled
    @GestureState var valueAtStartOfDrag: Double?
    
    func drag(updating value: Binding<Double>, in bounds: ClosedRange<Double>, width: Double) -> some Gesture {
        DragGesture(minimumDistance: 1)
            .updating($valueAtStartOfDrag) { dragValue, state, _ in
                if state == nil {
                    state = value.wrappedValue
                }
            }
            .onChanged { dragValue in
                if let newValue = valueForTranslation(dragValue.translation.width, in: bounds, width: width) {
                    var transaction = Transaction()
                    transaction.isContinuous = true
                    withTransaction(transaction) {
                        value.wrappedValue = newValue
                    }
                }
            }
            .onEnded { dragValue in
                if let newValue = valueForTranslation(dragValue.translation.width, in: bounds, width: width) {
                    value.wrappedValue = newValue
                }
            }
    }
    
    func makeBody(configuration: Configuration) -> some View {
        LabeledContent {
            HStack {
                Button {
                    withAnimation {
                        configuration.value = configuration.bounds.lowerBound
                    }
                } label: {
                    configuration.minimumValueLabel
                }
                .buttonStyle(.plain)
                
                GeometryReader { proxy in
                    ZStack(alignment: .leading) {
                        Rectangle()
                            .fill(.regularMaterial)
                        Rectangle()
                            .fill(isEnabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.gray.opacity(0.5)))
                            .frame(width: relativeValue(for: configuration.value, in: configuration.bounds) * proxy.size.width)
                    }
                    .contentShape(Rectangle())
                    .gesture(drag(updating: configuration.$value, in: configuration.bounds, width: proxy.size.width))
                }
                .frame(height: 44)
                .mask(RoundedRectangle(cornerRadius: 8, style: .continuous))
                
                Button {
                    withAnimation {
                        configuration.value = configuration.bounds.upperBound
                    }
                } label: {
                    configuration.maximumValueLabel
                }
                .buttonStyle(.plain)
            }
        } label: {
            configuration.label
        }
        .onChange(of: valueAtStartOfDrag != nil) { newValue in
            configuration.onEditingChanged(newValue)
        }
    }
    
    func relativeValue(for value: Double, in bounds: ClosedRange<Double>) -> Double {
        let boundsLength = bounds.upperBound - bounds.lowerBound
        let fraction = (value - bounds.lowerBound) / boundsLength
        return max(0, min(fraction, 1))
    }
    
    func valueForTranslation(_ x: Double, in bounds: ClosedRange<Double>, width: Double) -> Double? {
        guard let initialValue = valueAtStartOfDrag, width > 0 else { return nil }
        let relativeTranslation = x / width
        let boundsLength = bounds.upperBound - bounds.lowerBound
        let scaledTranslation = relativeTranslation * boundsLength
        let newValue = initialValue + scaledTranslation
        let clamped = max(bounds.lowerBound, min(newValue, bounds.upperBound))
        return clamped
    }
}

extension MySliderStyle where Self == CustomMySliderStyle {
    static var custom: Self { .init() }
}

// MARK: - Example View

struct MySliderContentView: View {
    @State var value = 0.2
    @State var value2 = 0.2
    @State var isEnabled = true
    
    var body: some View {
        VStack(spacing: 32) {
//            Toggle("Enabled", isOn: $isEnabled)
            
            Group {
                MySlider(value: $value, in: 0.0...1.0) {
                    Text("Volume")
                } minimumValueLabel: {
                    Image(systemName: "speaker")
                } maximumValueLabel: {
                    Image(systemName: "speaker.wave.3")
                } onEditingChanged: { isEditing in
                    print(isEditing)
                }
                
                Divider()
                
                MySlider(value: $value2, in: 0.0...1.0) {
                    Text("Volume")
                } minimumValueLabel: {
                    Image(systemName: "speaker")
                } maximumValueLabel: {
                    Image(systemName: "speaker.wave.3")
                } onEditingChanged: { isEditing in
                    print(isEditing)
                }
                .mySliderStyle(.custom)
                .labelsHidden()
                
            }
            .disabled(!isEnabled)
        }
        .tint(.orange)
        .padding()
        .frame(width: 320)
    }
}

#Preview {
    MySliderContentView()
}

四、实现样式开发2:枚举

比较简单的方式就还是用枚举了,其扩展性肯定没有 Styling 大,不过很简单。

  1. 样式与内容分离;
  2. 级联性:上层统一设置子视图的样式,子视图单独设置的话不会被覆盖。【所以很多修饰符都是对 View 扩展的,方便级联性】

自定义 Style 方式(Enum + Environment):

  1. 定义一个 EnvironmentKey,定义好样式的枚举类型【同 .plain/.bordered 等】;
  2. 添加一个 EnvironmentValues 为定义好的 Key 类型;
  3. 定义一个 CustomView,根据该 Values 进行 UI 布局【同 Button】;
  4. 给 View 添加一个扩展方法方便使用该环境值【同 .buttonStyle(...)】;
  5. 使用该 CustomView,并用该扩展指定想要的样式即可【同 .plain/.bordered 等】。

扩展:需要修改枚举,不如 Styling 方式的扩展性强。

enum ButtonSize: EnvironmentKey {
    static var defaultValue: ButtonSize?
    
    case small
    case regular
    case large
    
    var width: CGFloat {
        switch self {
        case .small:
            return 25
        case .regular:
            return 50
        case .large:
            return 80
        }
    }
    
    var height: CGFloat { width }
}

extension EnvironmentValues {
    var buttonSize: ButtonSize? {
        get { self[ButtonSize.self] }
        set { self[ButtonSize.self] = newValue }
    }
}

extension View {
    func buttonSize(_ size: ButtonSize?) -> some View {
        self.environment(\.buttonSize, .regular)
    }
}

struct ClumsyButton_2: View {
    var symbol: String
    var action: () -> ()
    
    @Environment(\.buttonSize) private var buttonSize
    
    var body: some View {
        ZStack {
            Circle()
            
            Image(systemName: symbol)
                .foregroundColor(.accentColor)
        }
        .onTapGesture(perform: action)
        .frame(width: buttonSize?.width ?? 50, height: buttonSize?.height ?? 50)
    }
}

#Preview {
    VStack {
        ClumsyButton_2(symbol: "plus", action: {})
        
        ClumsyButton_2(symbol: "square.and.arrow.up", action: {})
            .environment(\.buttonSize, .regular) // 直接用key-value
           .buttonSize(.large) // 使用View扩展更方便
        
        ClumsyButton_2(symbol: "person", action: {})
    }
    .environment(\.buttonSize, .small)
    .foregroundColor(.green)
    .accentColor(.white)
    .previewLayout(.fixed(width: 100, height: 400))
}

参考