一、字节 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))
}

参阅