SwiftUI:让你的边框动起来(三)—trim

571 阅读3分钟

有时候需要边框有线条流动的动画效果,接下来几篇文章会来探讨下不同的实现方式。本文主要是介绍第三种方式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
截屏2024-09-28 17.43.17.png
所以要实现边框流动的效果很直接的一个思路就是让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这个起始点的左右两侧,于是就会变成下面这样,在一个周期结束的时候会有个跳变
Sep-28-2024 20-19-01.gif
要解决这个问题,可以当线段横跨这个临界点时,变成两部分,比如一段是从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。这里有两种方式:

  1. 通过实现Animatable让progress变成可以被系统动画插值计算的
  2. 通过TimelineView自己计算progress
    最终的效果都类似下图(两条线段使用了不同的颜色,方便查看最终整个线段横跨起点两侧时的情况):
    Oct-07-2024 21-51-32.gif
  • 通过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是在研究边框动画时整理的三种不同的实现方式,希望对大家有所帮助。

SwiftUI:让你的边框动起来(一)——dashPhase
SwiftUI:让你的边框动起来(二)——mask