前言
最近想用SwiftUI重写之前其他人写的库RippleButton。
一切进行的都很顺利,除了Ripple的出现和消失的动画。
在做初代一杯的时候,我使用了很多的Timer
。这是最可控思路最简单的也是自由度最大的方案,但总觉得不是那么优雅。尤其是对于Animation
来说,它不是那么容易优雅地中断动画或在动画未结束的时候更改动画的终点。
因此,在之前我自己写的Wave中,我也花了很多心血来寻找完全使用原生Animation
的方案。秉持着这个想法,我在写RButton
的时候也希望尽可能采用原生的方案,至少完全不使用Timer
。
不过这回这么简单的一个想法,实现起来却遇到了一个很诡异的问题…
思路
SwiftUI和UIKit的思维不一样。
对UIKit来说,我完全可以把一个UIView
添加为一个大UIView
(在这里就是按钮)的子UIView
,我也可以在全局有一个变量方便我随时掌控这个子UIView
(也就是Ripple)的状态,我还可以直接通过父UIView
的属性去访问他的所有子UIView。因此,情况很简单:我只需要显式地为UIView的出现和消失添加动画即可。ZFRippleButton库的作者就是这么做的:
UIView.animate(withDuration: 0.1, delay: 0, options: UIViewAnimationOptions.allowUserInteraction, animations: {
self.rippleBackgroundView.alpha = 1
}, completion: nil)
rippleView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
UIView.animate(withDuration: 0.7, delay: 0, options: [UIViewAnimationOptions.curveEaseOut, UIViewAnimationOptions.allowUserInteraction], animations: {
self.rippleView.transform = CGAffineTransform.identity
}, completion: nil)
可是对于SwiftUI来说,View何时添加何时被删除并不是我能掌控的事情。View需要被关联到一些属性上,根据属性,SwiftUI自己掌控这个View的出现和消失。 因此,我的思路是,创建一个类,里面包含了每次点按的位置,再用一个数组存储所有的点按。当按下的时候向数组添加元素,松手的时候移除。随后使用ForEach根据点按情况创建多个View。同时,我对于View可以使用Transition来达到想要的出现消失效果。
问题
探索的过程我就不展开了,以下是我面临的一些有代表性的问题。
起因
我的写法是这样的:
public struct RButton<Content: View, Background: View, Ripple: View>: View {
init(@ViewBuilder content: () -> Content, @ViewBuilder background: () -> Background, @ViewBuilder ripple: @escaping () -> Ripple, action: @escaping () -> Void, appearDuration: Double = 0.4, disappearDuration: Double = 2, rippleRadius: CGFloat = 50) {
self.content = content()
self.background = background()
self.ripple = ripple
self.action = action
self.appearDuration = appearDuration
self.disappearDuration = disappearDuration
self.rippleRadius = rippleRadius
}
private var content: Content
private var background: Background
private var ripple: () -> Ripple
private var action: (()->Void)
private var appearDuration: Double
private var disappearDuration: Double
private var rippleRadius: CGFloat
@State private var disabled: Bool = false
@State private var ripples: [RippleParameter] = []
public var body: some View {
ZStack {
Color.primary
ZStack {
background
content
}
.opacity(disabled ? 0.5 : 1)
Color.primary.opacity(disabled ? 0.5 : 0)
ForEach(ripples, id: (\.id)) { r in
ripple()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
}
.pressPosition(beginAction: { pos in
withAnimation {
ripples.append(RippleParameter(pos: pos))
}
}, endAction: { _ in
withAnimation {
if !ripples.isEmpty {
_ = ripples.removeFirst()
}
}
})
.animation(.easeInOut(duration: 0.3), value: disabled)
}
}
fileprivate class RippleParameter: ObservableObject, Identifiable {
init(pos: CGPoint) {
self.pos = pos
}
var id = UUID()
var pos: CGPoint
}
理论上会放大出现并淡化消失,可是情况却是这样:
可以看到,开始的Transition是有用的,但消失的时候完全没有动画。与此对比的是:
排查
从最简单的不使用外部类,数组只存随机的Int,到开始存外部类…截止我写到这里的时候,我已经新增了@ViewBuilder的相关代码,主打一个贴合出问题的原本代码…
…可是一切正常…
struct test: View {
@State fileprivate var list: [RippleParameter] = []
var veryhappy: () -> some View = happy
@ViewBuilder static func happy() -> some View {
Color.gray
}
var body: some View {
VStack {
Button(action: {
withAnimation {
list.append(RippleParameter(pos: CGPoint(x: 70, y: 70)))
}
}, label: {
Text("Add")
})
Button(action: {
withAnimation {
_ = list.removeFirst()
}
}, label: {
Text("Remove")
})
ForEach(list, id: \.id) { item in
veryhappy()
.frame(width: 100, height: 100, alignment: .center)
.transition(.asymmetric(insertion: .slide, removal: .opacity))
}
}
.frame(width: 300, height: 300, alignment: .center)
}
}
fileprivate class RippleParameter: ObservableObject, Identifiable {
init(pos: CGPoint) {
self.pos = pos
}
var id = UUID()
var pos: CGPoint
}
#Preview {
test()
}
到底怎么回事?我已经写得这么接近问题代码了,却依然没出现问题?
让我们再让他更接近一点…
…难道是我自己写的Modifier PressPosModifier的问题?
var body: some View {
VStack {
Color.orange.frame(width: 150, height: 80, alignment: .center)
.pressPosition { pos in
withAnimation {
list.append(RippleParameter(pos: CGPoint(x: 70, y:
70)))
}
} endAction: { _ in
withAnimation {
_ = list.removeFirst()
}
}
ForEach(list, id: \.id) { item in
veryhappy()
.frame(width: 100, height: 100, alignment: .center)
.transition(.asymmetric(insertion: .slide, removal:
.opacity))
}
}
.frame(width: 300, height: 300, alignment: .center)
}
简直不要太丝滑好吗…那到底是什么原因呢…
多爽啊…这才是我想要的效果啊…
于是我继续探索…
我当时在想,那实在不行我直接在这个代码的基础上改,抛弃之前那个代码不也行吗,只是我实在不知道这俩代码到底有什么区别,为什么一个可以一个不行…
于是我尝试把它改成ZStack…
一切都开始改变…
注意我只是把VStack改成ZStack,没有更改层级,理论上方块的层级应该永远高于Orange,可是为什么消失的时候方块跑到Orange后面去了?
我们先不管背后的原因,那么有没有一种可能…
…其实方块(Ripple)一直都有Transition,只是刚开始动画的那个瞬间就被挡住了?
破案
于是我们回到之前的代码…
把按钮主体加上.opacity(0.2)
,但不改变Ripple…
…现在我们确实能看到灰色方块是渐变消失的了…
最终成果
终于达成了我想要的效果…
这丝滑的动画
太爽了…
还我时间
想了很多导致Transition失效的原因都没想到过还有这种可能性… 我感觉我花了好几个小时…十几个小时??去探究这个问题… 我差点就发到StackOverflow上去问了… SwiftUI你 #还我时间…
下回讲UnitPoint、overlay与ZStack的区别、层级