本篇文章将会非常有趣,相信我,看完这篇文章一定会收获满满。
什么是Style
相信大家在学习SwiftUI过程中,一定接触了类似于ButonStyle
,ToggleStyle
这样的东西。 拿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返回不同的样式就可以了,也就是未按压显示一种样式,按压后显示另一种样式。
实现上图中的按压高亮效果的代码如下:
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秒后,才会触发按钮的点击事件,触发后,会显示上方的文字:
代码如下:
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的状态返回一个自定义的样式,效果如下:
直接看代码:
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点,先埋一个小伏笔,我们会在下边介绍如何实现这项技术。
大家再看下边这个效果:
- 点击后,正向翻转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
这一小节,是本篇文章的核心,学会后,我们就可以自定义任何含有内在逻辑的交互控件了。先给大家看一下效果图:
- 该控件的内在逻辑有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。
这基本上是一个固定的套路 ,这些代码完全可以复用。大家可以细品一下在前边加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/,如有侵权,立即删除。