[英]How to make zoom in zoom out button animation on tap gesture in SwiftUI?
Simple and regular approach to animate a bump effect for a button but not simple in SwiftUI.为按钮设置凹凸效果的简单而常规的方法,但在 SwiftUI 中并不简单。
I'm trying to change scale
in tapGesture
modifier, but it doesn't have any effect.我正在尝试更改tapGesture
修饰符中的scale
,但它没有任何效果。 I don't know how to make chain of animations, probably because SwiftUI doesn't have it.我不知道如何制作动画链,可能是因为 SwiftUI 没有它。 So my naive approach was:所以我天真的方法是:
@State private var scaleValue = CGFloat(1)
...
Button(action: {
withAnimation {
self.scaleValue = 1.5
}
withAnimation {
self.scaleValue = 1.0
}
}) {
Image("button1")
.scaleEffect(self.scaleValue)
}
Obviously it doesn't work and buttons image get last scale value immediately.显然它不起作用,按钮图像立即获得最后一个比例值。
My second thought was to change scale to 0.8
value on hold
event and then after release
event make scale to 1.2
and again after few mseconds change it to 1.0
.我的第二个想法是在hold
事件上将 scale 更改为0.8
值,然后在release
事件后将 scale 更改为1.2
,并在几毫秒后再次将其更改为1.0
。 I guess this algorithm should make nice and more natural bump effect.我想这个算法应该会做出更好更自然的凹凸效果。 But I couldn't find suitable gesture
struct in SwiftUI to handle hold-n-release
event.但我在 SwiftUI 中找不到合适的gesture
结构来处理hold-n-release
事件。
PS For ease understanding, I will describe the steps of the hold-n-release
algorithm: PS为了便于理解,我将描述hold-n-release
算法的步骤:
1.0
比例值为1.0
0.8
按钮比例变为0.8
1.2
按钮比例变为1.2
0.1
sec延迟0.1
秒1.0
按钮刻度 go 回到默认值1.0
UPD: I found a simple solution using animation delay
modifier. UPD:我找到了一个使用 animation delay
修改器的简单解决方案。 But I'm not sure it's right and clear.但我不确定它是否正确和清晰。 Also it doens't cover hold-n-release
issue:它也不涵盖hold-n-release
问题:
@State private var scaleValue = CGFloat(1)
...
Button(action: {
withAnimation {
self.scaleValue = 1.5
}
//
// Using delay for second animation block
//
withAnimation(Animation.linear.delay(0.2)) {
self.scaleValue = 1.0
}
}) {
Image("button1")
.scaleEffect(self.scaleValue)
}
UPD 2 : I noticed in solution above it doesn't matter what value I pass as argument to delay
modifier: 0.2
or 1000
will have same effect. UPD 2 :我注意到在上面的解决方案中,我将什么值作为参数传递给delay
修饰符并不重要: 0.2
或1000
将具有相同的效果。 Perhaps it's a bug也许这是一个错误
So I've used Timer
instance instead of delay
animation modifier.所以我使用了Timer
实例而不是delay
animation 修饰符。 And now it's working as expected:现在它按预期工作:
...
Button(action: {
withAnimation {
self.scaleValue = 1.5
}
//
// Replace it
//
// withAnimation(Animation.linear.delay(0.2)) {
// self.scaleValue = 1.0
// }
//
// by Timer with 0.5 msec delay
//
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
withAnimation {
self.scaleValue = 1.0
}
}
}) {
...
UPD 3 : Until we waiting official Apple update, one of suitable solution for realization of two events touchStart
and touchEnd
is based on @average Joe answer : UPD 3 :在我们等待苹果官方更新之前,实现两个事件touchStart
和touchEnd
的合适解决方案之一是基于@average Joe 回答:
import SwiftUI
struct TouchGestureViewModifier: ViewModifier {
let minimumDistance: CGFloat
let touchBegan: () -> Void
let touchEnd: (Bool) -> Void
@State private var hasBegun = false
@State private var hasEnded = false
init(minimumDistance: CGFloat, touchBegan: @escaping () -> Void, touchEnd: @escaping (Bool) -> Void) {
self.minimumDistance = minimumDistance
self.touchBegan = touchBegan
self.touchEnd = touchEnd
}
private func isTooFar(_ translation: CGSize) -> Bool {
let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
return distance >= minimumDistance
}
func body(content: Content) -> some View {
content.gesture(DragGesture(minimumDistance: 0)
.onChanged { event in
guard !self.hasEnded else { return }
if self.hasBegun == false {
self.hasBegun = true
self.touchBegan()
} else if self.isTooFar(event.translation) {
self.hasEnded = true
self.touchEnd(false)
}
}
.onEnded { event in
if !self.hasEnded {
let success = !self.isTooFar(event.translation)
self.touchEnd(success)
}
self.hasBegun = false
self.hasEnded = false
}
)
}
}
extension View {
func onTouchGesture(minimumDistance: CGFloat = 20.0,
touchBegan: @escaping () -> Void,
touchEnd: @escaping (Bool) -> Void) -> some View {
modifier(TouchGestureViewModifier(minimumDistance: minimumDistance, touchBegan: touchBegan, touchEnd: touchEnd))
}
}
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 2 : 1)
}
}
struct Test2View: View {
var body: some View {
Button(action: {}) {
Image("button1")
}.buttonStyle(ScaleButtonStyle())
}
}
Yes, it looks like a bug but after my experimenting I found that you can do so是的,它看起来像一个错误,但经过我的实验,我发现你可以这样做
I've post a demo at https://youtu.be/kw4EIOCp78g我在https://youtu.be/kw4EIOCp78g发布了一个演示
struct TestView: View {
@State private var scaleValue = CGFloat(1)
var body: some View {
ZStack {
CustomButton(
touchBegan: {
withAnimation {
self.scaleValue = 2
}
},
touchEnd: {
withAnimation {
self.scaleValue = 1
}
}
){
Image("button1")
}.frame(width: 100, height: 100)
Image("button1").opacity(scaleValue > 1 ? 1 : 0).scaleEffect(self.scaleValue)
}
}
}
struct CustomButton<Content: View>: UIViewControllerRepresentable {
var content: Content
var touchBegan: () -> ()
var touchEnd: () -> ()
typealias UIViewControllerType = CustomButtonController<Content>
init(touchBegan: @escaping () -> (), touchEnd: @escaping () -> (), @ViewBuilder content: @escaping () -> Content) {
self.touchBegan = touchBegan
self.touchEnd = touchEnd
self.content = content()
}
func makeUIViewController(context: Context) -> UIViewControllerType {
CustomButtonController(rootView: self.content, touchBegan: touchBegan, touchEnd: touchEnd)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}
class CustomButtonController<Content: View>: UIHostingController<Content> {
var touchBegan: () -> ()
var touchEnd: () -> ()
init(rootView: Content, touchBegan: @escaping () -> (), touchEnd: @escaping () -> ()) {
self.touchBegan = touchBegan
self.touchEnd = touchEnd
super.init(rootView: rootView)
self.view.isMultipleTouchEnabled = true
}
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
self.touchBegan()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
self.touchEnd()
}
//touchesEnded only works if the user moves his finger beyond the bound of the image and releases
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
self.touchEnd()
}
}
There is another strange thing if we move and scale the second Image to the first then it will not be shown without .frame(width: 100, height: 100)
.还有一件奇怪的事情,如果我们将第二个图像移动并缩放到第一个图像,那么如果没有.frame(width: 100, height: 100)
它将不会显示。
Nice and clean swiftUI
solution:干净整洁swiftUI
解决方案:
@State private var scaleValue = CGFloat(1)
...
Image("button1")
.scaleEffect(self.scaleValue)
.onTouchGesture(
touchBegan: { withAnimation { self.scaleValue = 1.5 } },
touchEnd: { _ in withAnimation { self.scaleValue = 1.0 } }
)
however, you also need to add this code snippet to the project:但是,您还需要将此代码段添加到项目中:
struct TouchGestureViewModifier: ViewModifier {
let touchBegan: () -> Void
let touchEnd: (Bool) -> Void
@State private var hasBegun = false
@State private var hasEnded = false
private func isTooFar(_ translation: CGSize) -> Bool {
let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
return distance >= 20.0
}
func body(content: Content) -> some View {
content.gesture(DragGesture(minimumDistance: 0)
.onChanged { event in
guard !self.hasEnded else { return }
if self.hasBegun == false {
self.hasBegun = true
self.touchBegan()
} else if self.isTooFar(event.translation) {
self.hasEnded = true
self.touchEnd(false)
}
}
.onEnded { event in
if !self.hasEnded {
let success = !self.isTooFar(event.translation)
self.touchEnd(success)
}
self.hasBegun = false
self.hasEnded = false
})
}
}
extension View {
func onTouchGesture(touchBegan: @escaping () -> Void,
touchEnd: @escaping (Bool) -> Void) -> some View {
modifier(TouchGestureViewModifier(touchBegan: touchBegan, touchEnd: touchEnd))
}
}
Upgrading code for answer for iOS 15 (available since iOS 13). iOS 15 的答案升级代码(自 iOS 13 起可用)。 The one-parameter form of the animation() modifier has now been formally deprecated, mostly because it caused all sorts of unexpected behaviors (for ex in Lazy stacks: lazyhgrid, lazyvgrid): Button animated unexpectedly (jumping) during scrolling. animation() 修饰符的单参数形式现在已被正式弃用,主要是因为它会导致各种意外行为(例如在惰性堆栈中:lazyhgrid、lazyvgrid):按钮在滚动期间意外动画(跳跃)。
public struct ScaleButtonStyle: ButtonStyle {
public init() {}
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1)
.animation(.linear(duration: 0.2), value: configuration.isPressed)
.brightness(configuration.isPressed ? -0.05 : 0)
}
}
public extension ButtonStyle where Self == ScaleButtonStyle {
static var scale: ScaleButtonStyle {
ScaleButtonStyle()
}
}
Ok, I think I might have a decent solution here.好的,我想我可能在这里有一个不错的解决方案。 GIST here 要点在这里
I've put together a bunch of things to make this work.我已经把很多东西放在一起来完成这项工作。 First, a AnimatableModifier that observes if an animation has ended.首先,AnimatableModifier 观察 animation 是否已结束。 All thanks to avanderlee多亏了avanderlee
/// An animatable modifier that is used for observing animations for a given animatable value.
public struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
/// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
public var animatableData: Value {
didSet {
notifyCompletionIfFinished()
}
}
/// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
private var targetValue: Value
/// The completion callback which is called once the animation completes.
private var completion: () -> Void
init(observedValue: Value, completion: @escaping () -> Void) {
self.completion = completion
self.animatableData = observedValue
targetValue = observedValue
}
/// Verifies whether the current animation is finished and calls the completion callback if true.
private func notifyCompletionIfFinished() {
guard animatableData == targetValue else { return }
/// Dispatching is needed to take the next runloop for the completion callback.
/// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
DispatchQueue.main.async {
self.completion()
}
}
public func body(content: Content) -> some View {
/// We're not really modifying the view so we can directly return the original input value.
return content
}
}
public extension View {
/// Calls the completion handler whenever an animation on the given value completes.
/// - Parameters:
/// - value: The value to observe for animations.
/// - completion: The completion callback to call once the animation completes.
/// - Returns: A modified `View` instance with the observer attached.
func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: @escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
}
}
Now that we can track the end of a animation, another view modifer takes care of tracking the end of the zoom out animation and starts a new zoom in animation using a bunch of booleans to track the animation state. Now that we can track the end of a animation, another view modifer takes care of tracking the end of the zoom out animation and starts a new zoom in animation using a bunch of booleans to track the animation state. Forgive the naming.原谅命名。
struct ZoomInOutOnTapModifier: ViewModifier {
var destinationScaleFactor: CGFloat
var duration: TimeInterval
init(duration: TimeInterval = 0.3,
destinationScaleFactor: CGFloat = 1.2) {
self.duration = duration
self.destinationScaleFactor = destinationScaleFactor
}
@State var scale: CGFloat = 1
@State var secondHalfAnimationStarted = false
@State var animationCompleted = false
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.simultaneousGesture(DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
.onChanged({ _ in
animationCompleted = true
withAnimation(.linear(duration: duration)) {
scale = destinationScaleFactor
}
})
.onEnded({ _ in
withAnimation(.linear(duration: duration)) {
scale = 1
}
secondHalfAnimationStarted = true
})
)
.onAnimationCompleted(for: scale) {
if scale == 1 {
secondHalfAnimationStarted = false
animationCompleted = true } else if scale == destinationScaleFactor {
animationCompleted = false
secondHalfAnimationStarted = true
}
if !secondHalfAnimationStarted {
withAnimation(.linear(duration: duration)) {
scale = 1
}
}
}
}
}
public extension View {
func addingZoomOnTap(duration: TimeInterval = 0.3, destinationScaleFactor: CGFloat = 1.2) -> some View {
modifier(ZoomInOutOnTapModifier(duration: duration, destinationScaleFactor: destinationScaleFactor))
}
}
All put together:全部放在一起:
PlaygroundPage.current.setLiveView(
Button {
print("Button tapped")
} label: {
Text("Tap me")
.font(.system(size: 20))
.foregroundColor(.white)
.padding()
.background(Capsule()
.fill(Color.black))
}
.addingZoomOnTap()
.frame(width: 300, height: 300)
)
Let me know if improvements can be made.让我知道是否可以改进。
EDIT:编辑:
In case you want the button to be in the scaled state until the user lets go(touchUp) of the button, the ViewModifier become much simpler.如果您希望按钮位于缩放的 state 中,直到用户松开按钮(touchUp),ViewModifier 变得更加简单。
struct ZoomInOutOnTapModifier: ViewModifier {
var destinationScaleFactor: CGFloat
var duration: TimeInterval
init(duration: TimeInterval = 0.3,
destinationScaleFactor: CGFloat = 1.2) {
self.duration = duration
self.destinationScaleFactor = destinationScaleFactor
}
@State var scale: CGFloat = 1
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.simultaneousGesture(DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
.onChanged({ _ in
withAnimation(.linear(duration: duration)) {
scale = destinationScaleFactor
}
})
.onEnded({ _ in
withAnimation(.linear(duration: duration)) {
scale = 1
}
})
)
}
}
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.