I want the items inside LazyVGrid to have a tap modifier (change their scale) and at the same time be able to scroll. I set up a tap modifier for each element and it really works, but the ability to scroll the content disappears, but if I disable my custom tap effect, then scrolling becomes available again. How can I make a click effect and the ability to scroll the content at the same time?
struct ScaledTappable: ViewModifier {
@State var state = false
var tapHandler: () -> Void
func body(content: Content) -> some View {
content
.scaleEffect(state ? 0.9 : 1)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ value in
withAnimation(.smooth(duration: 0.2)) {
state = true
}
})
.onEnded({ value in
withAnimation(.bouncy(duration: 0.5)) {
state = false
tapHandler()
}
})
)
}
}
extension View {
@ViewBuilder
func tappable(enabled: Bool = true, onTap: @escaping () -> Void) -> some View {
if enabled {
self.modifier(ScaledTappable(tapHandler: onTap))
} else {
self.opacity(0.3)
}
}
}
Instead of using a DragGesture
to intercept taps, try using .onTapGesture
. Then, use a completion callback on the animation to perform the follow-on action (requires iOS 17):
// ScaledTappable
func body(content: Content) -> some View {
content
.scaleEffect(state ? 0.9 : 1)
.onTapGesture {
withAnimation(.smooth(duration: 0.2)) {
state = true
} completion: {
withAnimation(.bouncy(duration: 0.5)) {
state = false
tapHandler()
}
}
}
}
Another way to perform the animation would be to use .phaseAnimator
(also requires iOS 17):
struct ScaledTappable: ViewModifier {
@State private var trigger = 0
var tapHandler: () -> Void
func body(content: Content) -> some View {
content
.onTapGesture {
trigger += 1
tapHandler()
}
.phaseAnimator([false, true], trigger: trigger) { content, phase in
content
.scaleEffect(phase ? 0.9 : 1)
} animation: { phase in
phase ? .smooth(duration: 0.2) : .bouncy(duration: 0.5)
}
}
}
For earlier iOS versions, consider using an Animatable
ViewModifier
for performing the follow-on action after the first part of the animation has completed. See this answer for a generic implementation.
Example of doing it this way:
struct ScaledTappable: ViewModifier {
@State private var scalingFactor: CGFloat = 1
var tapHandler: () -> Void
func body(content: Content) -> some View {
content
.scaleEffect(scalingFactor)
.onTapGesture {
withAnimation(.smooth(duration: 0.2)) {
scalingFactor = 0.9
}
}
// See https://stackoverflow.com/a/76969841/20386264
.modifier(AnimationCompletionCallback(animatedValue: scalingFactor) {
if scalingFactor < 1 {
withAnimation(.bouncy(duration: 0.5)) {
scalingFactor = 1
tapHandler()
}
}
})
}
}
Example use (same for all implementation variants):
ScrollView {
LazyVStack {
ForEach(1..<100) { i in
Text("Row \(i)")
.frame(maxWidth: .infinity)
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.fill(.yellow)
}
.tappable {
print("row \(i) tapped")
}
}
}
.padding()
}