一、字节 DanceUI 中的 Styling 机制
字节的沙龙中提到 Styling 机制的用途:
- 使视图能以最适合当时上下文的办法显示
- 使视图款式与行为逻辑解耦别离
- 使不同的视图款式在各个业务场景中复用
代码示例:
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 办法,自界说一个新的款式只需要两个过程:
- 自界说一个 Style,遵从某个 Style 协议,完成 makeBody 办法来做自界说操作;
- 扩展该协议,增加该 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 的扩展办法:
- 界说 MySlider View;
- 界说 MySliderStyleConfiguration:用于传参装备;
- 扩展 MySlider,运用 MySliderStyleConfiguration 作为 MySlider init 办法;
- 界说 MySliderStyle 协议,包括 makeBody(configuration: Configuration) 办法;
- 扩展 MySliderStyle 协议,增加 resolve(configuration: Configuration) 办法,完成为调用 style.makeBody 办法;
- 根据 5,MySlider 中的 body 完成为把所有传参给 Configuration,然后调用 resolve(configuration: Configuration) ,即调用了 style.makeBody 办法,完成了相似体系的自界说 style 且完成 makeBody 即可扩展款式的功用。(可是这儿经过 5 中转,才使得 UI 可交互,具体不明白,参阅文章中有具体过程);
—– MySlider 中的 style 特点怎么获取?
- 界说一个 EnvironmentKey、扩展 EnvironmentValues、扩展 View 写入该环境值;
- 因而,MySlider 中的 style 特点经过环境值获取,运用的地方经过扩展 View 的修饰符写入;
—– 运用(同体系办法):
- 自界说一个 Style,遵从某个 Style 协议,完成 makeBody 办法来做自界说操作;
- 扩展该协议,增加该 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 大,不过很简单。
- 款式与内容别离;
- 级联性:上层一致设置子视图的款式,子视图独自设置的话不会被覆盖。【所以很多修饰符都是对 View 扩展的,便利级联性】
自界说 Style 办法(Enum + Environment):
- 界说一个 EnvironmentKey,界说好款式的枚举类型【同 .plain/.bordered 等】;
- 增加一个 EnvironmentValues 为界说好的 Key 类型;
- 界说一个 CustomView,根据该 Values 进行 UI 布局【同 Button】;
- 给 View 增加一个扩展办法便利运用该环境值【同 .buttonStyle(…)】;
- 运用该 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))
}