iOS/SwiftUI: rotating device stops "offset" animation - ios

Here is the minimal example:
struct ContentView: View {
#State var offset: CGFloat = 0
#State var opacity: Double = 1
var body: some View {
Text("Hello, world!")
.opacity(opacity)
.offset(y: offset)
.onAppear {
withAnimation(.easeInOut.repeatForever()) {
offset = 30
}
withAnimation(.easeInOut.repeatForever()) {
opacity = 0
}
}
}
}
Both offset and opacity work fine; however, if I rotate the phone, opacity keeps animating (the words keep appearing and disappearing), but offset does not (the words stop jumping up and down).
Is it a bug? Is there some workaround?
Thanks.
Update: I can get notifications about device rotations using NotificationCenter and restart animation with an update.toggle() trick. It seems kinda hacky though. Maybe there is a better way?

Related

SwiftUI unexpected animations when toggling animations In .onAppear (using a GeometryReader's size)

I have a strange animation behavior in SwiftUI. I've tried to create a minimal view that demonstrates it below.
I want to animate in three circles with a fade and a scale effect (see column "What I Expect" below). However, the size of the circles depends on the width of the view, so I'm using a GeometryReader to get that.
I want to start the animation in .onAppear(perform:), but at the time that is called, the GeometryReader hasn't set the size property yet. What I end up with is the animation you see in "Unwanted Animation 1". This is due to the frames being animated from .zero to their correct sizes.
However, whenever I try to disable the animations for the frames by adding a .animation(nil, value: size) modifier, I get an extremely strange animation behavior (see "Unwanted Animation 2"). This I don't understand at all. It somehow adds a horizontal translation to the animation which makes it look even worse. Any ideas what's happening here and how to solve this?
Strangely, everything works fine if I use an explicit animation like this:
.onAppear {
withAnimation {
show.toggle()
}
}
But I want to understand what's going on here.
Thanks!
Update:
Would replacing .onAppear(perform:) with the following code be reasonable? This would trigger only once in the lifetime of the view, right when size changes from .zero to the correct value.
.onChange(of: size) { [size] newValue in
guard size == .zero else { return }
show.toggle()
}
What I Expect
Unwanted Animation 1
Unwanted Animation 2
import SwiftUI
struct TestView: View {
#State private var show = false
#State private var size: CGSize = .zero
var body: some View {
VStack {
circle
circle
circle
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.background {
GeometryReader { proxy in
Color.clear.onAppear { size = proxy.size }
}
}
.onAppear { show.toggle() }
}
private var circle: some View {
Circle()
.frame(width: circleSize, height: circleSize)
.animation(nil, value: size) // This make the circles animate in from the side for some reason (see "Strange Animation 2")
.opacity(show ? 1 : 0)
.scaleEffect(show ? 1 : 2)
.animation(.easeInOut(duration: 1), value: show)
}
private var circleSize: Double {
size.width * 0.2 // Everything works fine if this is a constant
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
The real size is known after first layout, but .onAppear is called before, so layout (including frame change, which is animatable) goes under animation.
To solve this we need to delay state change a bit (until first layout/render finished), like
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
show.toggle()
}
}
... and this is why withAnimation also works - it actually delays closure call to next cycle.
Tested with Xcode 13 / iOS 15

Why does iOS Status Bar collapses because of Blur Effect since iOS 15?

I encountered a strange problem since iOS 15: I have a Blur Effect on the App's Root View, which changes depending on the scenePhase.
This was working perfectly until iOS 15 got released. Now, whenever the Blur Effect is 0, the Status Bar of the App collapses and the Navigation Bar moves up and is no more interactable.
struct RootView: View {
#Environment(\.scenePhase) var scenePhase
#State private var blurRadius: CGFloat = 0
var body: some View {
Group {
OtherViews()
}
.blur(radius: blurRadius)
.onChange(of: scenePhase) { newValue in updateBlurRadius(newValue) }
}
private func updateBlurRadius(_ scenePhase: ScenePhase) {
switch scenePhase {
case .active : withAnimation { blurRadius = 0 }
case .inactive: withAnimation { blurRadius = 16 }
case .background: withAnimation { blurRadius = 16 }
#unknown default: print("Unknown Case")
}
}
}
This code worked fine for iOS 14 and before. However, since iOS 15, the following bug appears:
The curious thing is, that when the scenePhase becomes inactive, the Navigation Bar instantly jumps into its proper spot. And as soon as the scenePhase becomes active again, it jumps back to the top behind the Status Bar.
Also, when changing the Blur Radius for the active scenePhase to 0.001 instead of 0, everything works perfectly fine and the Navigation Bar does not jump behind the Status Bar.
Does anyone have an idea what could cause this strange behavior when working with Blur Effects?
Thanks a lot for your help in advance.
I had this exact issue and was not able to find a fix, so I am now using this alternative implementation, which does pretty much the same thing:
ZStack {
// View to be blurred goes here
Rectangle()
.ignoresSafeArea()
.foregroundStyle(.ultraThinMaterial)
.opacity(/* Your condition */ ? 1 : 0)
.animation(.easeInOut(duration: 0.2))
}
This will overlay a blurry rectangle over your view.
So in your case:
struct RootView: View {
#Environment(\.scenePhase) var scenePhase
var body: some View {
ZStack {
OtherViews()
Rectangle()
.ignoresSafeArea()
.foregroundStyle(.ultraThinMaterial)
.opacity(scenePhase != .active ? 1 : 0)
.animation(.easeInOut)
}
}
}
Because this solution uses the new materials it only works on iOS 15. You can use if #available(iOS 15, *) to provide two different implementations, on for iOS 15+ and one for iOS 14 and earlier.

How to use DispatchQueue to make a view appear and disappear after some time?

I want to make a view appear, do something, then disappear with DispatchQueue. I want to toggle the showHand to show the hand then toggleHandAnimation to make the hand move left and right. After some time e.g. 5 seconds, I want that hand to disappear.
I have an implementation below which seems to be working on the build but it seems like there would be a better way. I have the code below.
What is the guidance on implementing views where you want to run multiple tasks at different points in time async?
import SwiftUI
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.animation(Animation.easeInOut(duration: 0.6).repeatCount(5))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: {
toggleHandAnimation.toggle()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
})
}
}
}
}
I think this is what you are looking for:
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
ZStack {
Color(uiColor: .systemBackground)
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.onAppear {
withAnimation(Animation.easeInOut(duration: 0.5).repeatCount(10)) {
toggleHandAnimation.toggle()
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
}
}
}
}
}
A few things, you should read up on intrinsic vs. extrinsic animations. You were trying to use an intrinsic one, but for a situation like this, it should be extrinsic(i.e., withAnimation(). Also, .animation(_ animation:) has been deprecated because it does not work well. You should be using .animation(_ animation:, value:).
Also, I aligned the time so that the animation ends and the hand disappears. The nice thing with using the withAnimation() is that you can animate multiple things occur with the same animation.
As you can see, you didn't need to nest theDispatchQueues. You only needed the one to make the view disappear.
Lastly, I put this all in a ZStack and set a color on that, so that there was always a view to return. The reason is, this will crash if there is suddenly no view being returned. That is what would happen at the end of the animation with out the color. Obviously, you can have whatever view you want, that was just an example.

SwiftUI - Animating only view positions

I have an app in which I'm trying to animate different properties differently upon change. In the following demonstration app, a spring animation applies to both size and position when the "Flip" button is pressed:
Here is the code:
class Thing: Identifiable {
var id: Int
init(id: Int) {
self.id = id
}
}
struct ContentView: View {
#State var isFlipped: Bool = false
let thing1 = Thing(id: 1)
let thing2 = Thing(id: 2)
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 20) {
ForEach(isFlipped ? [thing2,thing1] : [thing1, thing2]) { thing in
Text("\(thing.id)").font(.system(size: 150, weight: .heavy))
.scaleEffect(isFlipped ? CGFloat(thing.id)*0.4 : 1.0)
.animation(.spring(response: 0.5, dampingFraction: 0.3))
}
}
Button("Flip") { isFlipped.toggle() }
}
}
}
My question is: how can I animate the positions without animating the scale?
If I remove the .scaleEffect() modifier, just the positions are animated.
But if I then insert it after the .animation() modifier, then no animation at all occurs, not even the positions. Which seems very strange to me!
I'm familiar with the "animation stack" concept - that which animations apply to which view properties depends on the order in which modifiers and animations are applied to the view. But I can't make sense of where the positions lie in that stack… or else how to think about what's going on.
Any thoughts?
EDITED: I changed the .scaleEffect() modifier to operate differently on the different Thing objects, to include that aspect of the problem I face; thank you to #Bill for the solution for the case when it doesn't.
How about scaling the HStack instead of Text?
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 20) {
ForEach(isFlipped ? [thing2,thing1] : [thing1, thing2]) { thing in
Text("\(thing.id)").font(.system(size: 150, weight: .heavy))
}
}
.animation(.spring(response: 0.5, dampingFraction: 0.3))
.scaleEffect(isFlipped ? 0.5 : 1.0)
Button("Flip") { isFlipped.toggle() }
}
}
This is only one year and five months late, but hey!
To stop the animation of scaleEffect it might work to follow the .scaleEffect modifier with an animation(nil, value: isFlipped) modifier.
Paul Hudson (Hacking With Swift) discusses multiple animations and the various modifiers here. You asked about the concepts involved and Paul provides a quick overview.
Alternatively, take a look at the code below. It is my iteration of the solution that Paul suggests.
/*
Project Purpose:
Shows how to control animations for a single view,
when multiple animations are possible
This view has a single button, with both the button's
background color and the clipShape under control
of a boolean. Tapping the button toggles the boolean.
The object is to make the change in clipShape
animated, while the change in background color
is instantaneous.
Take Home: if you follow an "animatable" modifier
like `.background(...)` with an `.animation` modifier
with an `animation(nil)' modifier then that will cancel
animation of the background change. In contrast,
`.animation(.default)` allows the previous animatable
modifier to undergo animation.
*/
import SwiftUI
struct ContentView: View {
#State private var isEnabled = false
var body: some View {
Button( "Background/ClipShape" )
{
isEnabled.toggle()
}
.foregroundColor( .white )
.frame( width: 200, height: 200 )
// background (here dependent on isEnabled) is animatable.
.background( isEnabled ? Color.green : Color.red )
// the following suppresses the animation of "background"
.animation( nil, value: isEnabled )
// clipshape (here dependent on isEnabled) is animatable
.clipShape(
RoundedRectangle(
cornerRadius: isEnabled ? 100 : 0 ))
// the following modifier permits animation of clipShape
.animation( .default, value: isEnabled )
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

SwiftUI ScrollView with Tap and Drag gesture

I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:
The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
Long pressing an entry should activate a drag gesture, so items can be moved around.
The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).
Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.
Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?
Thanks for helping me out!
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#GestureState var dragging = CGSize.zero
var body: some View {
Circle()
.frame(width: 100, height: 100)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragging, body: {t, state, _ in
state = t.translation
}))
.offset(dragging)
}
}
I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#State private var offset = CGSize.zero
#State private var isDragging = false
#GestureState var isTapping = false
var body: some View {
// Gets triggered immediately because a drag of 0 distance starts already when touching down.
let tapGesture = DragGesture(minimumDistance: 0)
.updating($isTapping) {_, isTapping, _ in
isTapping = true
}
// minimumDistance here is mainly relevant to change to red before the drag
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isDragging = false
}
}
let pressGesture = LongPressGesture(minimumDuration: 1.0)
.onEnded { value in
withAnimation {
isDragging = true
}
}
// The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
let combined = pressGesture.sequenced(before: dragGesture)
// The new combined gesture is set to run together with the tapGesture.
let simultaneously = tapGesture.simultaneously(with: combined)
return Circle()
.overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
.frame(width: 100, height: 100)
.foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
.offset(offset)
.gesture(simultaneously)
}
}
For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.
OpenScrollView for SwiftUI on Github
Credit to
https://stackoverflow.com/a/59897987/12764795
http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui

Resources