SwiftUI 动画进阶 - part2 几何效果
译自 https://swiftui-lab.com/swiftui-animations-part2/ 建议横屏阅读代码
Animatable
协议,以及如何用它来动画化路径。接下来我们将用同一个协议来动画化矩阵变换,并且用到一个新工具:
GeometryEffect
。如果你还没有读过第一部分,不知道
Animatable
协议是什么,我建议你先阅读这部分。不过,如果你关注的是
GeometryEffect
而不是动画,那也可以跳过第一部分,继续阅读。
几何效果
GeometryEffect
是一个遵循
Animatable
和
ViewModifier
的协议,为了遵循
GeometryEffect
,你需要实现下面的方法:
func effectValue(size: CGSize) -> ProjectionTransform
SkewEffect
,应用在视图上的方式如下:
Text("Hello").modifier(SkewEfect(skewValue: 0.5))
Text("Hello")
将会通过
SkewEfect.effectValue()
方法输出的值做矩阵变换。就这么简单,注意,改变会影响到这个视图,但并不影响它的父视图或者子视图。
GeometryEffect
也遵循
Animatable
,你也可以添加
animatableData
属性,换言之,你可以获得动画效果。
GeometryEffect
了。如果你使用过
.offset()
,实际上就是在用
GeometryEffect
。让我向你展示它是如何实现的:
public extension View {
func offset(x: CGFloat, y: CGFloat) -> some View {
return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
}
func offset(_ offset: CGSize) -> some View {
return modifier(_OffsetEffect(offset: offset))
}
}
struct _OffsetEffect: GeometryEffect {
var offset: CGSize
var animatableData: CGSize.AnimatableData {
get { CGSize.AnimatableData(offset.width, offset.height) }
set { offset = CGSize(width: newValue.first, height: newValue.second) }
}
public func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
}
}
动画关键帧
CGAffineTransform
的参数
c
影响倾斜,而参数
tx
影响 x 轴的偏移量。
struct SkewedOffset: GeometryEffect {
var offset: CGFloat
var skew: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(offset, skew) }
set {
offset = newValue.first
skew = newValue.second
}
}
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
}
}
仿造关键帧
struct SkewedOffset: GeometryEffect {
var offset: CGFloat
var pct: CGFloat
let goingRight: Bool
init(offset: CGFloat, pct: CGFloat, goingRight: Bool) {
self.offset = offset
self.pct = pct
self.goingRight = goingRight
}
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { return AnimatablePair<CGFloat, CGFloat>(offset, pct) }
set {
offset = newValue.first
pct = newValue.second
}
}
func effectValue(size: CGSize) -> ProjectionTransform {
var skew: CGFloat
if pct < 0.2 {
skew = (pct * 5) * 0.5 * (goingRight ? -1 : 1)
} else if pct > 0.8 {
skew = ((1 - pct) * 5) * 0.5 * (goingRight ? -1 : 1)
} else {
skew = 0.5 * (goingRight ? -1 : 1)
}
return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
}
}
.delay()
动画 modifier 让动画效果错开。
动画反馈
.rotation3DEffect()
,我们这个会有点特别。每当视图旋转到足以看到另外一面时,一个布尔绑定会相应更新。
实现效果
.rotationg3DEffect()
modifier 允许你指定锚点,我们这里是在构建自定义效果,所以要自行处理锚点。由于我们无法修改锚点,我们得额外添加一些平移操作:
struct FlipEffect: GeometryEffect {
var animatableData: Double {
get { angle }
set { angle = newValue }
}
@Binding var flipped: Bool
var angle: Double
let axis: (x: CGFloat, y: CGFloat)
func effectValue(size: CGSize) -> ProjectionTransform {
// 我们得把改变安排在视图完成绘制之后
// 否则我们会收到一个运行时错误,指示我们在视图绘制期间修改状态
DispatchQueue.main.async {
self.flipped = self.angle >= 90 && self.angle < 270
}
let a = CGFloat(Angle(degrees: angle).radians)
var transform3d = CATransform3DIdentity;
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 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)
}
}
flipped
@Binding
属性。我们用它来给视图反馈现在视图的哪一面正朝向用户。
flipped
值来条件化显示两个视图中的其中一个。在这个特定的例子中,我们还加了一个小把戏。你注意看视频,卡牌一直在变化,背面总是相同的,但前面每一轮都变化。所以它不是简单地给一面显示一个视图,另一面显示另一个视图。每一轮,我们用不同的卡牌。
struct RotatingCard: View {
@State private var flipped = false
@State private var animate3d = false
@State private var rotate = false
@State private var imgIndex = 0
let images = ["diamonds-7", "clubs-8", "diamonds-6", "clubs-b", "hearts-2", "diamonds-b"]
var body: some View {
let binding = Binding<Bool>(get: { self.flipped }, set: { self.updateBinding($0) })
return VStack {
Spacer()
Image(flipped ? "back" : images[imgIndex]).resizable()
.frame(width: 265, height: 400)
.modifier(FlipEffect(flipped: binding, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
.rotationEffect(Angle(degrees: rotate ? 0 : 360))
.onAppear {
withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
self.animate3d = true
}
withAnimation(Animation.linear(duration: 8.0).repeatForever(autoreverses: false)) {
self.rotate = true
}
}
Spacer()
}
}
func updateBinding(_ value: Bool) {
// If card was just flipped and at front, change the card
if flipped != value && !flipped {
self.imgIndex = self.imgIndex+1 < self.images.count ? self.imgIndex+1 : 0
}
flipped = value
}
}
Color.clear.overlay(ViewSwapper(showFront: flipped))
.frame(width: 265, height: 400)
.modifier(FlipEffect(flipped: $flipped, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
struct ViewSwapper: View {
let showFront: Bool
var body: some View {
Group {
if showFront {
FrontView()
} else {
BackView()
}
}
}
}
让视图跟随路径
-
如何获取路径中某一个特定的点的坐标 -
在沿着路径移动时,如何确定视图的朝向。在这个特定的例子中,我们要如何知道应该将飞机的机头朝向哪里(友情提示:需要用到一些三角学的知识)
找出路径上的 x,y 位置
Path
结构体的
.trimmedPath()
modifier。给定起始百分比和结束百分比,这个方法会返回一个
CGRect
,它包含了路径片段的边界。对于我们的需求,我们只需要以很近的起始和结束点来调用它,这样就能得到一个很小的矩形,然后用它的中心点作为我们的 x,y 位置。
func percentPoint(_ percent: CGFloat) -> CGPoint {
// percent difference between points
let diff: CGFloat = 0.001
let comp: CGFloat = 1 - diff
// handle limits
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
let f = pct > comp ? comp : pct
let t = pct > comp ? 1 : pct + diff
let tp = path.trimmedPath(from: f, to: t)
return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}
找出方向
func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
let a = pt2.x - pt1.x
let b = pt2.y - pt1.y
let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
return CGFloat(angle)
}
组合在一起
struct FollowEffect: GeometryEffect {
var pct: CGFloat = 0
let path: Path
var rotate = true
var animatableData: CGFloat {
get { return pct }
set { pct = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
if !rotate { // Skip rotation login
let pt = percentPoint(pct)
return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
} else {
let pt1 = percentPoint(pct)
let pt2 = percentPoint(pct - 0.01)
let angle = calculateDirection(pt1, pt2)
let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
return ProjectionTransform(transform)
}
}
func percentPoint(_ percent: CGFloat) -> CGPoint {
// percent difference between points
let diff: CGFloat = 0.001
let comp: CGFloat = 1 - diff
// handle limits
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
let f = pct > comp ? comp : pct
let t = pct > comp ? 1 : pct + diff
let tp = path.trimmedPath(from: f, to: t)
return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}
func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
let a = pt2.x - pt1.x
let b = pt2.y - pt1.y
let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
return CGFloat(angle)
}
}
IgnoredByLayout()
GeometryEffect
旅程的最后一站是
.ignoredByLayout()
方法。让我们先瞧一眼文档怎么说:
.ignoredByLayout()
来展示一些比较明显的效果。从这些效果中我们可以看出,根据添加方式的不同,
GeometryReader
是如何报告不同的位置的(即,有无
.ignoredByLayout()
的区别)。
struct ContentView: View {
@State private var animate = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 5)
.foregroundColor(.green)
.frame(width: 300, height: 50)
.overlay(ShowSize())
.modifier(MyEffect(x: animate ? -10 : 10))
RoundedRectangle(cornerRadius: 5)
.foregroundColor(.blue)
.frame(width: 300, height: 50)
.overlay(ShowSize())
.modifier(MyEffect(x: animate ? 10 : -10).ignoredByLayout())
}.onAppear {
withAnimation(Animation.easeInOut(duration: 1.0).repeatForever()) {
self.animate = true
}
}
}
}
struct MyEffect: GeometryEffect {
var x: CGFloat = 0
var animatableData: CGFloat {
get { x }
set { x = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: x, y: 0))
}
}
struct ShowSize: View {
var body: some View {
GeometryReader { proxy in
Text("x = \(Int(proxy.frame(in: .global).minX))")
.foregroundColor(.white)
}
}
}
下一步
GeometryEffect
是简单的,它只有一个方法需要实现,而它的可能性却是无限的。我们要做的只是发挥一点想象力。
AnimatableModifier
。假如你觉得
GeometryEffect
算是强大的,那你也可以对
AnimatableModifier
期待满满了。