有时候需要边框有线条流动的动画效果,接下来几篇文章会来探讨下不同的实现方式。本文主要是介绍第三种方式trim。
public func trim(from startFraction: CGFloat = 0, to endFraction: CGFloat = 1) -> some Shape
trim方法可以让形状显示一部分,
RoundedRectangle(cornerRadius: 20)
.trim(from: 0, to: 0.25)
.stroke(style: .init(lineWidth: 6, lineCap: .round))
.foregroundStyle(.indigo)
.frame(width: 200, height: 200)
比如上面这段代码就可以只显示矩形边框的前1/4
所以要实现边框流动的效果很直接的一个思路就是让start和end保持一个固定的距离往前推进
RoundedRectangle(cornerRadius: 20)
.trim(from: start, to: end)
.stroke(style: .init(lineWidth: 6, lineCap: .round))
.foregroundStyle(.indigo)
.frame(width: 200, height: 200)
Button("Start") {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
start = 0.75
end = 1
}
}
但是这么做有个问题就是线段没办法横跨在0这个起始点的左右两侧,于是就会变成下面这样,在一个周期结束的时候会有个跳变
要解决这个问题,可以当线段横跨这个临界点时,变成两部分,比如一段是从0.9到1,还有一段是0到0.15, 所以可以通过两条线段来实现最终的效果,具体会分两种情况:
- 线段横跨临界点:通过组合两条线段来形成最终的线段
- 线段没有横跨临界点:只需要展示一条线段
// 用于计算两种情况下两条线段的起点和终点
final class TrimDataContext: ObservableObject {
@Published var start1: CGFloat = 0
@Published var end1: CGFloat = 0
@Published var start2: CGFloat = 0
@Published var end2: CGFloat = 0
private let totalLength = 0.25
func updateProgress(_ progress: CGFloat) {
if progress + totalLength <= 1 {
start1 = progress
end1 = progress + totalLength
start2 = 0
end2 = 0
} else {
let part2Length = progress + totalLength - 1
let part1Length = totalLength - part2Length
start1 = 1 - part1Length
end1 = 1
start2 = 0
end2 = part2Length
}
}
}
上面的progress是指边框流动的进度,所以接下来重点就是看怎么可以逐步去修改这个progress。这里有两种方式:
- 通过实现Animatable让progress变成可以被系统动画插值计算的
- 通过TimelineView自己计算progress
最终的效果都类似下图(两条线段使用了不同的颜色,方便查看最终整个线段横跨起点两侧时的情况):
- 通过Animatable协议
struct TrimConainerV1: View {
struct TrimV1: View, Animatable {
@StateObject var context = TrimDataContext()
var progress: CGFloat = 0.0
var animatableData: CGFloat {
get { progress }
set {
progress = newValue
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.foregroundStyle(.cyan)
.frame(width: 200, height: 200)
RoundedRectangle(cornerRadius: 20)
.trim(from: context.start1, to: context.end1)
.stroke(style: .init(lineWidth: 6, lineCap: .round))
.foregroundStyle(.indigo)
.frame(width: 200, height: 200)
RoundedRectangle(cornerRadius: 20)
.trim(from: context.start2, to: context.end2)
.stroke(style: .init(lineWidth: 6, lineCap: .round))
.foregroundStyle(Color.mint)
.frame(width: 200, height: 200)
}
.onChange(of: progress) { newValue in
context.updateProgress(newValue)
}
}
}
@Binding var isAnimating: Bool
@State private var progress: CGFloat = 0
var body: some View {
TrimV1(progress: progress)
.onChange(of: isAnimating) { newValue in
if newValue {
withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
progress = 1
}
}
}
}
}
- 通过TimelineView
struct TrimV2: View {
@StateObject var context = TrimDataContext()
@Binding var isAnimating: Bool
@State var progress: CGFloat = 0.0
@State private var lastTimeInterval: TimeInterval?
var body: some View {
TimelineView(.animation(paused: !isAnimating)) { ctx in
ZStack {
// ....
}
.onChange(of: ctx.date) { _ in
if let lastTimeInterval = lastTimeInterval {
let elapsedTime = ctx.date.timeIntervalSince1970 - lastTimeInterval
progress += elapsedTime * 0.2
if progress > 1 {
progress = 0
}
}
lastTimeInterval = ctx.date.timeIntervalSince1970
}
}
.onChange(of: progress) { newValue in
context.updateProgress(newValue)
}
}
}
详细的代码可以查看这里
总结
dashPhase, mask, trim是在研究边框动画时整理的三种不同的实现方式,希望对大家有所帮助。