原文首发于:丝滑的 iOS 进度条解锁交互到底是怎么制作的?

这篇是拆解吉光卡片 App 底部彩蛋入口的动画实现。将这个入口发到社区后,很多小伙伴表示对实现原理比较感兴趣,于是便有了这篇文章,我将从思路和原理入手,带大家抽丝剥茧地带大家了解这个动画是如何实现的。新手可以通过这个文章,了解实现的原理,老手可以看代码来更深入了解实现的细节。

而立之年的首页横幅

我将尽可能详细地拆解 吉光卡片小彩蛋的实现原理,确保大家可以理解并掌握其中的所有步骤。动画效果使用 SwiftUI 来实现,所以你可以看到其强大的功能和灵活性。我希望你们会喜欢这个教程,并从中学到新的技巧和知识。我会贴上必要的部分代码块,有兴趣可以自行查看或忽略。

 

渐变光

在这个动画中,极其重要的点在于对渐变光的视觉把控。关键的细节在于如何通过模拟物理世界点光源的扩散与衰减效应模拟出真实世界的感觉。另外就是在光的边缘,如果通过遮罩层对光进行裁切,保证光不会被发散出去。

渐变光进度条加载展示

物理

通过叠加多层的渐进阴影,可以得到一种更柔和,更接近物理效果的散射光。这种效果就像是光从光源发出后,边缘会逐渐变得柔和,而远离光源的光则会逐渐散开,给人一种非常自然和真实的感觉。

当时在家里拍视频的场景

 

一般来说,我们可以通过叠加三层或更多层的渐进阴影来达到这种效果。渐变层的透明度、扩散大小,阴影数值没有太多的规律可言,关键还是尽可能还原物理世界的规律,适当的调整各层的扩散大小和透明度数值,通常,扩散小的层次透明度数值大,扩散大的层次透明度数值小,通过这样的非线性叠加,可以得到非常理想的效果。

当时在家里拍视频的场景
.shadow(color: .accent.opacity(0.3), radius: 80)
.shadow(color: .accent.opacity(0.5), radius: 60)
.shadow(color: .accent.opacity(0.6), radius: 20)
.shadow(color: .accent.opacity(0.7), radius: 8)

遮罩

在 SwiftUI 中,如果有一些多余的部分需要去除,我们可以采用一个非常简单的方法,就是通过加入一个矩形的遮罩来达到我们的目的。这个矩形遮罩的作用就是将那些我们不需要的、多余的光线部分进行裁剪,从而使得我们的设计更加精准和符合我们的期望。这种方法不仅简单,而且效果非常好,可以帮助我们在设计中达到更好的效果。

当时在家里拍视频的场景
.contentShape(Rectangle())

 

进度条的实现

百分比

在 SwiftUI 中,GeometryReader 它提供了几何坐标读取的功能,用来读取该空间中的大小和位置信息,这是一个非常有用的工具。利用这个工具,我们可以在最外层添加一个撑开的宽度修饰器,这样我们就可以获得一个动态宽度的进度条。具体来说,这个进度条的宽度会根据我们设置的参数动态地进行调整,非常灵活且易于使用。

当时在家里拍视频的场景

宽度变化

此时这里的 progress 是一个百分比,它可以表示进度条的完成程度。例如,如果我们设置每隔 0.01 秒增加 0.01,也就是 1% 的 progress,那么要跑完整条进度条就需要 0.01 x 100 = 1秒。这样一来,我们就可以根据需要设置进度条的速度,让它以我们希望的速度进行加载。但是这样我们可能会得到一条线性增长的进度条,这可能并不是我们想要的效果。

@State private var progress: CGFloat = 0
@State private var timer: Timer?
@State private var isProgressCompleted: Bool = false

var body: some View {
    GeometryReader { geometry in
        RoundedRectangle(cornerRadius: 12, style: .continuous)
            .frame(width: isProgressCompleted ? geometry.size.width : geometry.size.width * progress)
    }
    .frame(maxWidth: .infinity)
}

蓄力的实现

这个方法实现了三个功能,这三个功能都是在用户使用过程中非常重要的:

松手的时候会归位。这是一个贴近物理的反馈,因为它可以保证在用户松手后,进度条会自动归位,不会对其他操作产生影响。

渐进式的宽度增量。通过这个功能,我们可以看到进度条的宽度是逐渐增加的,而不是突然增加,这可以带来更好的用户体验。

具有增量的震动提示。这个功能可以在进度条增加的同时,给用户提供震动提示,增加用户的使用感觉。

加速度

那么,怎么去获取到一条有加速度的更接近物理蓄力感觉的进度条呢?我们采取了一个简单的方法,就是设置了一个常量增量 acceleration 为 0.00007。这样,每次增加的量就不同,且呈指数型增加。所以在完成第一步的时候,progress 就变成了 0.007% + 0.07% = 0.077%,所以可以看到宽度的变化最开始是缓慢的,随着不断的相加,这个进度条就只需要 1.72秒 就可以从 0% 到 100%。

当时在家里拍视频的场景

进度条只要不满足到 100% 的条件,isProgressCompleted 这个条件就是 false,也就会执行重置计时器,这就是 resetProgress() 这个方法所要做的事情,让进度条回归到 0。假如进度条达到了 100%,那么 isProgressCompleted 就会是 true,那么循环就结束,用户可以看到进度条的完整过程。

震动

震动提示是通过 UIKit 里面来实现的,有不同的挡位的震动可以选择,这里选择了一个比较轻的挡位 soft。这里的方法其实跟宽度的加速度增量类似,也是一个递增的方法且跟随进度条的宽度变化而变化的。当进度超过 0.1%,会产生一次震动,根据上面第一个 10 毫秒才变化了 0.07% 的进度,可以发现是一个先慢后快的进度反馈,跑完震动条大概会震动 75 次,这样的设计可以增加用户的使用感觉,提升用户体验。

这里有个值得分享的,震动的频次不能过高,我一开始设置的是只要的宽度发生变化,就震动一次,首先是会震动到你手麻,其次是发现震动反馈有些许卡顿。所以才重新设置了震动阈值,来“降低”震动的频次。

当时在家里拍视频的场景
    func startProgress() {
        // 取消之前的计时器
        timer?.invalidate()
        isProgressCompleted = false // 重置进度完成状态
        var increment = 0.0007 // 初始进度增量
        let acceleration = 0.00007 // 进度增量的加速度

        let feedbackGenerator = UIImpactFeedbackGenerator(style: .soft)
        feedbackGenerator.prepare()

        // 定义震动的阈值,例如每增加1%时触发一次
        let vibrationThreshold: CGFloat = 0.01

        // 记录上次震动的进度
        var lastVibrationProgress: CGFloat = 0

        // 创建一个新的计时器
        timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [self] timer in
            // 更新进度条的进度
            self.progress += increment

            // 增加下一次的进度增量,模拟加速效果
            increment += acceleration

            // 当进度超过上次震动进度+阈值时,触发震动
            if self.progress >= lastVibrationProgress + vibrationThreshold {
                feedbackGenerator.impactOccurred()
                lastVibrationProgress = self.progress
            }

            // 确保进度不会超过1
            if self.progress >= 1 {
                // 当进度条完成时,保持进度状态,不重置
                timer.invalidate()
                self.progress = 1
                self.isProgressCompleted = true // 标记进度完成
            }
        }
    }

    func resetProgress() {
        // 取消计时器并重置进度条
        timer?.invalidate()
        progress = 0
        isProgressCompleted = false // 重置进度完成状态
    }

 

长按手势

序列化手势/手势链

开始和重置的两个方法是要搭配手势来实现的,这里通过 sequenced 来完成前后两件事情,一件是长按开始,另外一个未达条件,去归零进度条。如果只是往上叠手势,这些手势其实是异步的且权重是一样的。而手势其实跟图层是一样的,是可以设置优先级和前后顺序的。下面的例子是一个同步的例子。

当时在家里拍视频的场景
   LongPressGesture(minimumDuration: 0.5)
            .onEnded { _ in
                withAnimation {
                    if !isProgressCompleted { // 如果进度未完成,则开始
                        startProgress()
                    }
                }
            }
            .sequenced(before: DragGesture(minimumDistance: 0))
            .onEnded { _ in
                withAnimation {
                    if !isProgressCompleted { // 如果进度未完成,则重置
                        resetProgress()
                    } else {
                  //
                    }
                }
            }

矩形缩放

神奇移动/Magic move标注

矩形的缩放是通过一个强大的方法 matchedGeometryEffect 来实现的。这个修饰器的使用实际上是非常简单直接的,它与AE(After Effects)里面的动画标记效果有些类似。在使用这个修饰器时,你只需要定义动画的初始帧和结束帧,然后它会自动处理其他的帧,也就是中间的过渡帧。这种方式可以让动画过程看起来非常流畅自然。

为了能够准确地识别这两个帧,SwiftUI 提供了一种属性包装器,叫做Namespace。Namespace的主要作用就是给初始帧和结束帧进行唯一标识,保证动画的正确性。除此之外,我们还可以利用ifElse 的语句来实现元素的切换。比如,我们可以让一个元素在某个时刻消失,然后另外一个元素出现,而在这个过程中,所有的动画变化都会被自动补齐。这就是说,你不需要手动去处理每一帧的变化,所有的过程都是自动的,极大地方便了开发。

当时在家里拍视频的场景
@State private var scaleIntoOneCard = false // 缩成一张卡片
  @Namespace private var shapeTransition // 几何动画切换
  
   if !scaleIntoOneCard {
         progressbar()
           .matchedGeometryEffect(id: "card", in: shapeTransition)
   } else{
				RoundedRectangle(cornerRadius: 4)
				   .matchedGeometryEffect(id: "card", in: shapeTransition)
   }

 

一张变六张

在上面的描述中,我们提到了一张卡片,这张卡片已经没有用处了,所以我们直接让它消失。但是,这个过程对用户来说是不可见的。实际上,在卡片消失的0.5秒前,我们已经提前偷偷地把后面的六张卡片用ZStack堆叠起来了。

这种堆叠方式其实就是我们熟悉的初始帧和结束帧的逻辑,其中一个是前后堆叠的ZStack,另一个是水平堆叠的HStack。所有的变化动画都是由matchedGeometryEffect来完成的。每一张卡片的边际依次从1到6,这样就能形成一个良好的视觉效果。在这个过程中,我们需要注意的一点是,id前后一定要一一对应,否则会造成混乱。这是一个非常重要的细节,可能会导致整个动画效果出现问题。

当时在家里拍视频的场景
	 ZStack {
            ForEach(0 ..< 6) { index in
                card()
                    .matchedGeometryEffect(id: "card\(index)", in: shapeTransition) // 动画标记
            }
        }
     HStack {
            ForEach(0 ..< 6) { index in
                card()
                    .matchedGeometryEffect(id: "card\(index)", in: shapeTransition)
            }
        }

 

所以总结来说就是,长按拉满进度条后,立刻变成一张卡片,然后单张卡片消失,接着一张卡片变成六张卡片,六张卡片消失,出现密码输入框。这个动画的过渡效果其实可以类比 keynote 里面的 magic move 的效果。

输入框

等宽数字

类似验证码的输入框,是可以通过六个 Textfield 来实现的,然后再通过 Focusfield 来实现焦点的游走,但是我懒。后面的输入框背景是假的,我用的是一个完整的 textfield。

通过控制字体的字体间距来实现,这个时候一定要设置等宽的数字,保证每个间距都是一样的。

当时在家里拍视频的场景
 .kerning(20)
 .font(.system(size: 13).weight(.bold).monospaced())

晃动

这里有个密码错误的晃动提示,我也采用了一个偷懒的方法,通常来说一个递减 x 轴的 offset 来控制一个左右晃动的效果。而我是在这个输入框通过一个弹性动画来实现的,也就是当输入框刚要向 x轴的偏移 4 个单位的时候,就被强行停止了。

停止的动画是有弹性的,所以还需要多几帧才可以归位到 0。这样就讨巧地实现了一个 shake 的效果。

当时在家里拍视频的场景
 .offset(x: start ? 4 : 0)
 
 //
 
 start = true
 withAnimation(Animation.spring(response: 0.2, dampingFraction: 0.2, blendDuration: 0)) {
 start = false
 }

 

消失弥散过渡效果

输入框成功的消失动画,使用了一个自定义的复合过渡效果,是带透明度和大小的变化。这也是苹果喜欢用的一个过渡,比如说 ipad 上的 spotlight 的消失效果。

其实很好理解,结束帧是一个放大和模糊的状态,这里可以控制 radius 和 scale 来控制整个效果,而这个强度的调整其实跟即将消失的物体的大小也是息息相关的。

当时在家里拍视频的场景
private struct BlurModifier: ViewModifier {
    public let isIdentity: Bool
    public var intensity: CGFloat

    public func body(content: Content) -> some View {
        content
            .blur(radius: isIdentity ? intensity : 0)
            .opacity(isIdentity ? 0 : 1)
    }
}

public extension AnyTransition {
    static var blur: AnyTransition {
        .blur()
    }

    static var blurWithoutScale: AnyTransition {
        .modifier(
            active: BlurModifier(isIdentity: true, intensity: 5),
            identity: BlurModifier(isIdentity: false, intensity: 5)
        )
    }

    static func blur(
        intensity: CGFloat = 5,
        scale: CGFloat = 0.9,
        scaleAnimation animation: Animation = .spring()
    ) -> AnyTransition {
        .scale(scale: scale)
            .animation(animation)
            .combined(
                with: .modifier(
                    active: BlurModifier(isIdentity: true, intensity: intensity),
                    identity: BlurModifier(isIdentity: false, intensity: intensity)
                )
            )
    }
}

 

动画控制

DispatchQueue.main.asyncAfter 是一个常用的方法,用于在主线程上延迟执行代码。这个方法非常适用于需要稍后执行操作,但不阻塞当前线程的情况。这对于改善用户界面的响应性或等待某些条件成熟后再执行操作特别有用。我还喜欢嵌套来使用,比如说在执行了某个指令,完成后0.5秒再执行另外一个指令。这样连加减法的延迟都不用计算了。

当时在家里拍视频的场景
  DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                                withAnimation {
                                    showSixCards = true
                                }
                                // 再一秒之后显示输入密码
                                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                                    withAnimation {
                                        showPasswordInput = true
                                    }
                                }
                            }

 

完结散花~很高兴能跟大家分享一些设计的小细节,这是一篇设计和技术五五分成的文章,其实可以发现,懂视觉的人不懂技术,经常受限于实现。换句话说,技术限制了你对于设计的想象,而技术有能力实现,但是又缺乏对设计颗粒度的认知,很多开发经常说:“没必要,又看不出来”。他们之间还是隔了一条很大的缝隙,割裂,马里亚纳海沟!社会发展的形态的分工就似乎只让我们“专注”在一个事情,而很多事情的完成本质上一个连贯的多学科复合的事情,至少我是这么理解的。

文章写得比较仓促,尽可能通过图解的方式带大家了解实现的原理,如果对文章有任何困惑或不理解的地方,欢迎反馈。

吉光卡片 App 应用地址: