在前两篇文章中,我们已经讲解了如何使用Animatable
和GeometryEffect
来实现一些比较复杂的动画,其基本原理,是根据animatableData
来自由控制形变。
这篇文章中,我们将带来更为强大的一个工具AnimatableModifier
,它之所以强大,是因为它不仅实现了Animatable
协议,还实现了ViewModifier
协议,因此,我们能够利用这两个协议的优势。
基于ViewModifier
,我们可以直接返回some View
,这让我们能够不断的往原来的view上增加新的view。代码操作起来也更加灵活。
1. Animating Text
小试牛刀,我们看上边的gif图,这仍然是一个percent动画,根据percent(0~1),我们画一个路径,当然,该路径我们使用了path.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 20))
.
其中dashPhase
表示虚线开始的点,这里不多做解释,一个小技巧,利用这个值我们可以做一些有意思的动画:
struct ContentView: View {
@State private var phase: CGFloat = 0
var body: some View {
Rectangle()
.strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10], dashPhase: phase))
.frame(width: 200, height: 200)
.onAppear { self.phase -= 20 }
.animation(Animation.linear.repeatForever(autoreverses: false))
}
}
效果如下:
回到正文,大家仔细思考,我们如果用GeometryEffect
也能实现最上边进度的问题,但是我们无法显示30%,40%这样的问题。看看实现代码:
struct Example10: View {
@State private var pct: CGFloat = 0
var body: some View {
VStack {
Spacer()
Indicator(pct: pct)
Spacer()
HStack(spacing: 10) {
MyButton(label: "20%", font: .subheadline) {
withAnimation(.easeInOut(duration: 1.0)) {
self.pct = 0.2
}
}
MyButton(label: "60%", font: .subheadline) {
withAnimation(.easeInOut(duration: 1.0)) {
self.pct = 0.6
}
}
MyButton(label: "100%", font: .subheadline) {
withAnimation(.easeInOut(duration: 1.0)) {
self.pct = 1.0
}
}
}
Spacer()
}
}
}
struct Indicator: View {
var pct: CGFloat
var body: some View {
Circle()
.fill(LinearGradient(gradient: .init(colors: [.green, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 150, height: 150)
.modifier(IndicatorModirier(pct: pct))
}
}
struct IndicatorModirier: AnimatableModifier {
var pct: CGFloat = 0
var animatableData: CGFloat {
get {
pct
}
set {
pct = newValue
}
}
func body(content: Content) -> some View {
content
.overlay(ArcShape(pct: pct).foregroundColor(.orange))
.overlay(LabelView(pct: pct))
}
struct ArcShape: Shape {
var pct: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0),
radius: rect.height / 2.0 + 5.0,
startAngle: .degrees(0),
endAngle: .degrees(Double(pct) * 360),
clockwise: false)
return path.strokedPath(.init(lineWidth: 10, dash: [6, 3, 20, 3], dashPhase: 0))
}
}
struct LabelView: View {
var pct: CGFloat
var body: some View {
Text("\(pct * 100, specifier: "%.0f") %")
.font(.largeTitle)
.bold()
.foregroundColor(.white)
}
}
}
其中,最核心的代码是下边这几行,有了content,你就可以做出你想要的任何动画样式。
func body(content: Content) -> some View {
content
.overlay(ArcShape(pct: pct).foregroundColor(.orange))
.overlay(LabelView(pct: pct))
}
2. Animating Gradients
当我们平时需要对Gradient进行动画时会有一些限制,比如,我们可以对start point和end point进行动画,但是却无法对颜色进行动画。
但有了AnimatableModifier,就变得简单很多,我们先看把最终效果做进一步的拆分:
然后我们定义进度,我们仍然使用percent,它的值为0,0.1, 0.2 ... 1,这个值是系统根据动画需要自动计算的。
大家想一想,这个矩形区域上有很多像素,我们不可能把每一个像素对应于percent的变化都计算出来,每一次percent变化,我们只需要应用一个LinearGradient
就可以了:
func body(content: Content) -> some View {
var gColors = [Color]()
for i in 0..<from.count {
gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
}
return RoundedRectangle(cornerRadius: 15)
.fill(LinearGradient(gradient: .init(colors: gColors), startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 300, height: 300)
}
从上边的代码可以看出,我们修改了gColors
,而这个gColors
是通过下边的代码计算出来的:
func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
let rgbSpace = CGColorSpaceCreateDeviceRGB()
guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
return Color(c1)
}
guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
return Color(c2)
}
let r = cc1[0] + (cc2[0] - cc1[0]) * pct
let g = cc1[1] + (cc2[1] - cc1[1]) * pct
let b = cc1[2] + (cc2[2] - cc1[2]) * pct
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
==注意,类似于black,white这样的颜色,它的rgb颜色空间只有两个值,分别表示基于white的值和透明度,其他的颜色的rgb空间有4个值。==
说白了,就是根据percent混合from和to的颜色,在本例中,from混合的颜色是green
和yellow
,to混合的颜色是blue
和red
。
完整代码如下:
struct Example11: View {
@State private var animate = false
var body: some View {
let gradient1: [UIColor] = [.green, .blue]
let gradient2: [UIColor] = [.yellow, .red]
return VStack {
Spacer()
RoundedRectangle(cornerRadius: 15)
.frame(width: 300, height: 300)
.modifier(GradientAnimatabelModifier(from: gradient1, to: gradient2, pct: animate ? 1 : 0))
Spacer()
Button("颜色过渡") {
withAnimation(.easeInOut(duration: 1.0)) {
self.animate.toggle()
}
}
Spacer()
}
}
}
struct GradientAnimatabelModifier: AnimatableModifier {
let from: [UIColor]
let to: [UIColor]
var pct: CGFloat
var animatableData: CGFloat {
get {
pct
}
set {
pct = newValue
}
}
func body(content: Content) -> some View {
var gColors = [Color]()
for i in 0..<from.count {
gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
}
return RoundedRectangle(cornerRadius: 15)
.fill(LinearGradient(gradient: .init(colors: gColors), startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 300, height: 300)
}
func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
let rgbSpace = CGColorSpaceCreateDeviceRGB()
guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
return Color(c1)
}
guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
return Color(c2)
}
let r = cc1[0] + (cc2[0] - cc1[0]) * pct
let g = cc1[1] + (cc2[1] - cc1[1]) * pct
let b = cc1[2] + (cc2[2] - cc1[2]) * pct
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
}
3. More Text Animation
这一小节,主要讲解如何实现下边的动画:
很明显,动画是针对字符串中的字符来执行的,基本原理是随着percent的变化,字符的scale随之变化。
因此我们的问题变为如何根据percent计算相应位置的字符的scale?
我们设置一个具有一定宽度的区域,通过计算该区域的字符的位置来计算相应的scale,核心代码如下:
func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
let chunk = waveWidth / total
let m = 1 / chunk
let offset = (chunk - (1 / total)) * pct
let lowerLimit = (pct - chunk) + offset
let upperLimit = (pct) + offset
guard x >= lowerLimit && x < upperLimit else { return 0 }
let angle = ((x - pct - offset) * m)*360-90
return (sin(angle.rad) + 1) / 2
}
关于上边的代码,做几点说明,如果还有不明白的同学,可以留言:
chunk
表示scale范围的大小,其中waveWidth
表示scale的字符数,在本代码中的值为6- pct的取值范围是0~1,x的值通过
n/total
计算出来,因此x的取值范围为0..<1
offset
指的是pct点右便的距离,它动态变化lowerLimit
和upperLimit
是scale范围的上下限angle
表示角度,它的取值范围是-90~-450,因此(sin(angle.rad) + 1) / 2
计算的结果范围是0~1
scale范围计算的示意图:
反映到上图中angle
的取值范围为B点到A点,因此计算sin(angle.rad)
的取值范围正好是-1~1,这里边比较巧妙的是B->C是上升过程,C->A是下降过程,正好符合波形。
大家仔细思考就能够想明白其中奥妙,完整代码如下:
extension Double {
var rad: Double { return self * .pi / 180 }
var deg: Double { return self * 180 / .pi }
}
struct Example12: View {
@State private var flag = false
var body: some View {
VStack {
Spacer()
Color.clear.overlay(WaveText("The SwiftUI Lab", waveWidth: 6, pct: flag ? 1.0 : 0.0).foregroundColor(.blue)).frame(height: 40)
Color.clear.overlay(WaveText("swiftui-lab.com", waveWidth: 6, pct: flag ? 0.0 : 1.0, size: 18).foregroundColor(.green)).frame(height: 30)
Spacer()
}.onAppear {
withAnimation(Animation.easeInOut(duration: 2.0).repeatForever()) {
self.flag.toggle()
}
}.navigationBarTitle("Example 12")
}
}
struct WaveText: View {
let text: String
let pct: Double
let waveWidth: Int
var size: CGFloat
init(_ text: String, waveWidth: Int, pct: Double, size: CGFloat = 34) {
self.text = text
self.waveWidth = waveWidth
self.pct = pct
self.size = size
}
var body: some View {
Text(text).foregroundColor(Color.clear).modifier(WaveTextModifier(text: text, waveWidth: waveWidth, pct: pct, size: size))
}
struct WaveTextModifier: AnimatableModifier {
let text: String
let waveWidth: Int
var pct: Double
var size: CGFloat
var animatableData: Double {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.0) { (n, ch) in
Text(String(ch))
.font(Font.custom("Menlo", size: self.size).bold())
.scaleEffect(self.effect(self.pct, n, self.text.count, Double(self.waveWidth)))
}
}
}
func effect(_ pct: Double, _ n: Int, _ total: Int, _ waveWidth: Double) -> CGFloat {
let n = Double(n)
let total = Double(total)
return CGFloat(1 + valueInCurve(pct: pct, total: total, x: n/total, waveWidth: waveWidth))
}
func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
let chunk = waveWidth / total
let m = 1 / chunk
let offset = (chunk - (1 / total)) * pct
let lowerLimit = (pct - chunk) + offset
let upperLimit = (pct) + offset
guard x >= lowerLimit && x < upperLimit else { return 0 }
let angle = ((x - pct - offset) * m)*360-90
return (sin(angle.rad) + 1) / 2
}
}
}
4. Getting Creative
类似于这样的counter,看上去实现起来会非常麻烦,但是用AnimatableModifier
实现起来就非常easy。记住一点,AnimatableModifier
最牛逼的地方在于,能够让我们处理某一个时间点的状态,就好象把一系列的变化定格在某一时刻,我们只关心那一时刻的样式。
这个Counter最核心的想法就是分别计算十位数和个位数在某个数值时的offset。
举个例子,当数字为21时,他的offset为:
可能大家不理解,按理说21正好是整数,offset应该为0啊。其实你想的也没错,但是作者这里的代码是以n+1为基准的,21 + 1 是22, 正好offset为1.
再看一个21.3的例子
21 + 1 = 22 ,显示了22的offset为0.7,再看一个21.8的例子:
相信大家已经明白这个offset是什么意思了,上边演示的是个位数的offset,十位数的offset同理。完整代码如下:
struct Example13: View {
@State private var number: Double = 21
var body: some View {
VStack {
Spacer()
MovingCounter(number: number)
Spacer()
HStack {
MyButton(label: "35", font: .headline) {
withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) {
self.number = 35
}
}
MyButton(label: "44", font: .headline) {
withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) {
self.number = 44
}
}
MyButton(label: "87", font: .headline) {
withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) {
self.number = 87
}
}
}
Spacer()
}
}
}
struct MovingCounter: View {
var number: Double
var body: some View {
Text("00")
.modifier(CounterAnimatableModifier(number: number))
}
struct CounterAnimatableModifier: AnimatableModifier {
var number: Double
var animatableData: Double {
get {
number
}
set {
number = newValue
}
}
func body(content: Content) -> some View {
let n = self.number + 1
let uoffset = getOffsetForUnitDigit(n)
let toffset = getOffsetForTenDigit(n)
let u = [n - 2, n - 1, n, n + 1, n + 2].map{ getUnitDigit($0) }
let x = getTenDigit(n)
var t = [abs(x - 2), abs(x - 1), abs(x), abs(x + 1), abs(x + 2)]
t = t.map{ getUnitDigit(Double($0)) }
let font = Font.custom("Menlo", size: 34).bold()
return HStack(alignment: .top, spacing: 0) {
VStack {
Text("\(t[0])").font(font)
Text("\(t[1])").font(font)
Text("\(t[2])").font(font)
Text("\(t[3])").font(font)
Text("\(t[4])").font(font)
}
.foregroundColor(.green)
.modifier(ShiftEffect(pct: toffset))
VStack {
Text("\(u[0])").font(font)
Text("\(u[1])").font(font)
Text("\(u[2])").font(font)
Text("\(u[3])").font(font)
Text("\(u[4])").font(font)
}
.foregroundColor(.green)
.modifier(ShiftEffect(pct: uoffset))
}
.clipShape(CounterShap())
.overlay(CounterBorder())
.background(CounterBackground())
}
func getUnitDigit(_ number: Double) -> Int {
return abs(Int(number) - (Int(number) / 10) * 10)
}
func getTenDigit(_ number: Double) -> Int {
return abs(Int(number) / 10)
}
func getOffsetForUnitDigit(_ number: Double) -> CGFloat {
return 1 - CGFloat(number - Double(Int(number)))
}
func getOffsetForTenDigit(_ number: Double) -> CGFloat {
if getUnitDigit(number) == 0 {
return 1 - CGFloat(number - Double(Int(number)))
}
return 0
}
}
struct ShiftEffect : GeometryEffect {
var pct: CGFloat = 1.0
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX: 0, y: size.height / 5.0 * pct))
}
}
struct CounterShap: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let h = rect.height / 5.0 + 30
let r = CGRect(x: 0, y: (rect.height - h) * 0.5, width: rect.width, height: h)
path.addRoundedRect(in: r, cornerSize: CGSize(width: 5.0, height: 5.0))
return path
}
}
struct CounterBorder: View {
var body: some View {
GeometryReader { proxy in
RoundedRectangle(cornerRadius: 5.0)
.stroke(lineWidth: 5)
.foregroundColor(.blue)
.frame(width: 80, height: proxy.size.height / 5.0 + 30)
}
}
}
struct CounterBackground: View {
var body: some View {
GeometryReader { proxy in
RoundedRectangle(cornerRadius: 5.0)
.fill(Color.black)
.frame(width: 80, height: proxy.size.height / 5.0 + 30)
}
}
}
}
5. Animating Text Color
如果我们可以对某个View的foregroundColor
执行颜色的渐变动画,但是,当把这个动画放大文本上的时候,就不好使了,利用AnimatableModifier
可以轻松实现,这个动画的实现实在是太简单了,我们就不做更多的解释了,直接上代码:
struct Example15: View {
@State private var flag = false
var body: some View {
VStack {
AnimatableColorText(from: .systemRed, to: .systemBlue, pct: flag ? 1 : 0) {
Text("我是一个好人").font(.largeTitle).bold()
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 1.0)) {
self.flag.toggle()
}
}
}
}
struct AnimatableColorText: View {
let from: UIColor
let to: UIColor
let pct: CGFloat
let text: () -> Text
var body: some View {
let textView = text()
return textView.foregroundColor(.clear)
.modifier(AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView))
}
}
struct AnimatableColorTextModifier: AnimatableModifier {
let from: UIColor
let to: UIColor
var pct: CGFloat
let text: Text
var animatableData: CGFloat {
get {
pct
}
set {
pct = newValue
}
}
func body(content: Content) -> some View {
return text.foregroundColor(colorMixer(c1: from, c2: to, pct: pct))
}
func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
let rgbSpace = CGColorSpaceCreateDeviceRGB()
guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
return Color(c1)
}
guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
return Color(c2)
}
let r = cc1[0] + (cc2[0] - cc1[0]) * pct
let g = cc1[1] + (cc2[1] - cc1[1]) * pct
let b = cc1[2] + (cc2[2] - cc1[2]) * pct
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
}
总结
AnimatableModifier
的强大之处在于他即遵守了Animatable
协议,又是一个ViewModifier
,因此我们可以根据animatableData
来返回一个View。这就像把一段连续的动画,打散成一张张的图片。
注:上边的内容参考了网站https://swiftui-lab.com/swiftui-animations-part3/,如有侵权,立即删除。