Slider transition animation bug - swiftui

I have this strange animation bug I've not been able to figure out. When transitioning on a view that contains a slider, the slider appears to animate independently of the rest of the view.
For reference, here is the code that animates the two views:
#main
struct TransitionAnimationBug: App {
#State var displaySettings = false
var body: some Scene {
WindowGroup {
Group {
if displaySettings {
SliderView(displaySettings: $displaySettings)
.transition(AnyTransition.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
} else {
MainView(displaySettings: $displaySettings)
.transition(AnyTransition.asymmetric(
insertion: .move(edge: .leading),
removal: .move(edge: .trailing)
))
}
}
.animation(.easeInOut, value: displaySettings) // Shows bug
// .animation(.spring(), value: displaySettings) // Works
}
}
}
And here is the code from the settings screen that displays the slider:
struct SliderView: View {
var displaySettings: Binding<Bool>
#State var sliderValue: Double = 4.0
init(displaySettings: Binding<Bool>) {
self.displaySettings = displaySettings
}
var body: some View {
VStack(spacing: 30) {
Text("Hello, world!")
Slider(value: $sliderValue, in: 0 ... 4, step: 1)
.border(.red)
.padding(20)
Button("Close") { displaySettings.wrappedValue.toggle() }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.2))
}
}
And for completeness the main view:
struct MainView: View {
var displaySettings: Binding<Bool>
init(displaySettings: Binding<Bool>) {
self.displaySettings = displaySettings
}
var body: some View {
VStack(spacing: 30) {
Text("Hello, world!")
.padding()
Button("Show transition bug") {
displaySettings.wrappedValue.toggle()
}
Text("The slider will animate incorrectly")
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white)
}
}
As you can see this is a minimal app I built to show the bug (which I've submitted to Apple via their Feedback assistant), but I was wondering if anyone here as encountered it and found a solution.
Through experimenting I found that using a .spring() animation instead of .slideInOut correctly animates the slider, but any other animation fails.
Any thoughts?

This is effect of changing root view of window. The fix is to move the content of WindowGroup with all related states into separated view (to make root of window persistent), so it looks like
WindowGroup {
ContentView() // << everything else move here !!
}
Tested with Xcode 13.3 / iOS 15.4

Thanks Asperi, that worked. Do you know why? On first glance there's no obvious reason why it would work other than it being something to do with the fact that the body returns a Scene as opposed to a View.
The changed code looks like this:
#main
struct TransitionAnimationBug: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var displaySettings = false
var body: some View {
Group {
if displaySettings {
SliderView(displaySettings: $displaySettings)
.transition(AnyTransition.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
} else {
MainView(displaySettings: $displaySettings)
.transition(AnyTransition.asymmetric(
insertion: .move(edge: .leading),
removal: .move(edge: .trailing)
))
}
}
.animation(.easeInOut, value: displaySettings)
}
}

Related

SwiftUI ForEach animation overrides "local" animation

I have a view with an infinite animation. These views are added to a VStack, as follows:
struct PanningImage: View {
let systemName: String
#State private var zoomPadding: CGFloat = 0
var body: some View {
VStack {
Spacer()
Image(systemName: self.systemName)
.resizable()
.aspectRatio(contentMode: .fill)
.padding(.leading, -100 * self.zoomPadding)
.frame(maxWidth: .infinity, maxHeight: 200)
.clipped()
.padding()
.border(Color.gray)
.onAppear {
let animation = Animation.linear.speed(0.5).repeatForever()
withAnimation(animation) {
self.zoomPadding = abs(sin(zoomPadding + 10))
}
}
Spacer()
}
.padding()
}
}
struct ContentView: View {
#State private var imageNames: [String] = []
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(self.imageNames, id: \.self) { imageName in
PanningImage(systemName: imageName)
}
// Please uncomment to see the problem
// .animation(.default)
// .transition(.move(edge: .top))
}
}
.toolbar(content: {
Button("Add") {
self.imageNames.append("photo")
}
})
}
}
}
Observe how adding a row to the VStack can be animated, by uncommenting the lines in ContentView.
The problem is that if an insertion into the list is animated, the "local" infinite animation no longer works correctly. My guess is that the ForEach animation is applied to each child view, and somehow these animations influence each other. How can I make both animations work?
The issue is using the deprecated form of .animation(). Be careful ignoring deprecation warnings. While often they are deprecated in favor of a new API that works better, etc. This is a case where the old version was and is, broken. And what you are seeing is as a result of this. The fix is simple, either use withAnimation() or .animation(_:value:) instead, just as the warning states. An example of this is:
struct ContentView: View {
#State private var imageNames: [String] = []
#State var isAnimating = false // You need another #State var
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(self.imageNames, id: \.self) { imageName in
PanningImage(systemName: imageName)
}
// Please uncomment to see the problem
.animation(.default, value: isAnimating) // Use isAnimating
.transition(.move(edge: .top))
}
}
.toolbar(content: {
Button("Add") {
imageNames.append("photo")
isAnimating = true // change isAnimating here
}
})
}
}
}
The old form of .animation() had some very strange side effects. This was one.

NavigationLink in SwiftUI not worked as expected

I'm trying to make navigation link, here I'm creating NavigationLink with isActive based on State variable isLoggedIn. But without setting isLoggedIn true getting navigating to next screen.
also, it's navigating on tap of Email Textfield which is wrong.
My expectation is it should navigate only after isLoggedIn setting to true.
struct ContentView: View {
#State private var isLoggedIn = false
#State private var email = ""
var body: some View {
NavigationView {
NavigationLink(destination: Text("Second View"), isActive: $isLoggedIn) {
VStack {
TextField("Email", text: $email)
.frame(maxWidth: .infinity, alignment: .leading)
.border(.gray, width: 1)
.foregroundColor(.blue)
Button("Send") {
isLoggedIn = true
}
}
.padding()
}
}
}
}
The expectation is wrong, NavigationLink handles user input independently (but also, additionally, can be activated programmatically).
In this scenario, to leave only programmatic activation, we need to hide navigation link, like
NavigationView {
VStack {
TextField("Email", text: $email)
.frame(maxWidth: .infinity, alignment: .leading)
.border(.gray, width: 1)
.foregroundColor(.blue)
Button("Send") {
isLoggedIn = true
}
.background(NavigationLink(destination: // << here !!
Text("Second View"), isActive: $isLoggedIn) { EmptyView() })
}
.padding()
}
Here it's working fine with this
struct MoviesListView: View {
#State var navigate = false
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Hi"), isActive: $navigate) {
Button("Add") {
navigate.toggle()
}
}
}
}
}
}

Show BottomPlayerView above TabView in SwiftUI

I'm learning swiftUI and I want to make a music app.
I created a view which going to be above the tabView, but I want it to be shown only if user start playing a music.
My App, I use ZStack for bottomPlayer, and I share the bottomPlayer variable through .environmentObject(bottomPlayer) so the child views can use it:
class BottomPlayer: ObservableObject {
var show: Bool = false
}
#main
struct MyCurrentApp: App {
var bottomPlayer: BottomPlayer = BottomPlayer()
var audioPlayer = AudioPlayer()
var body: some Scene {
WindowGroup {
ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
TabBar()
if bottomPlayer.show {
BottomPlayerView()
.offset(y: -40)
}
}
.environmentObject(bottomPlayer)
}
}
}
The BottomPlayerView (above the TabView)
struct BottomPlayerView: View {
var body: some View {
HStack {
Image("cover")
.resizable()
.frame(width: 50, height: 50)
VStack(alignment: .leading) {
Text("Artist")
.foregroundColor(.orange)
Text("Song title")
.fontWeight(.bold)
}
Spacer()
Button {
print("button")
} label: {
Image(systemName: "play")
}
.frame(width: 60, height: 60)
}
.frame(maxWidth: .infinity, maxHeight: 60)
.background(Color.white)
.onTapGesture {
print("ontap")
}
}
}
My TabView:
struct TabBar: View {
var body: some View {
TabView {
AudiosTabBarView()
VideosTabBarView()
SearchTabBarView()
}
}
}
And In my SongsView, I use the EnvironmentObject to switch on the bottomPlayerView
struct SongsView: View {
#EnvironmentObject var bottomPlayer: BottomPlayer
var body: some View {
NavigationView {
VStack {
Button {
bottomPlayer.show = true
} label: {
Text("Show Player")
}
}
.listStyle(.plain)
.navigationBarTitle("Audios")
}
}
}
The problem is the bottomPlayer.show is actually set to true, but doesn't appear ...
Where I am wrong?
In your BottomPlayer add theĀ #Published attribute before the show boolean.
This creates a publisher of this type.
apple documentation

SwiftUI - Change direction of view hide animation

I've been perusing SO, and elsewhere, trying to find a way, if possible, to change the direction of how a view is hidden. The examples I've found hide a view by replacing it with an EmptyView, or changing the view's frame dimensions to zero. I am trying to hide a view by 'collapsing' it vertically, but everywhere I've looked the collapse/hide animation happens upwards.
In my case, a view will have a button underneath it that will collapse (hide) or expand (show) the view. The view and button are embedded in a scroll view. What I'd like to have happen is that when the view is wholly or partially scrolled up off the top of the screen, and the collapse button is tapped, the view should 'collapse downward' such that the button remains where it is. Everything I've tried causes the button to move upward off the screen.
I think that what has to happen is that the view origin's y value needs to change. But what would happen to all other views above this view? I've tried to accomplish what I've described by changing the transition but it's not having any affect.
I feel like I'm really missing something basic here so thanks for any help.
struct Collapsible<Content: View>: View {
private var content: () -> Content
#Binding var isCollapsed: Bool
#State var isOffscreen: Bool = false
init(isCollapsed: Binding<Bool>, content: #escaping () -> Content) {
self._isCollapsed = isCollapsed
self.content = content
}
var body: some View {
VStack {
content()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: isCollapsed ? 0 : nil)
.clipped()
.animation(.default, value: isCollapsed)
.transition(transition.combined(with: .opacity))
}
.background(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
let frame = proxy.frame(in: CoordinateSpace.global)
self.isOffscreen = frame.origin.y < 0
var _ = print("isOffscreen: \(self.isOffscreen)")
}
return Color.clear
}
)
}
var transition: AnyTransition {
if isOffscreen {
return AnyTransition.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
} else {
return AnyTransition.asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom))
}
}
}
struct ContentView: View {
#State var isCollapsed = false
var body: some View {
ScrollView {
VStack {
Color.clear.frame(height: 250)
Collapsible(isCollapsed: $isCollapsed) {
HStack {
Text("Content")
}
.frame(maxWidth: .infinity)
.padding([.top, .bottom], 75)
.background(.secondary)
.border(.blue, width: 2)
}
Button(action: {
self.isCollapsed.toggle()
}, label: {
Text(isCollapsed ? "Expand" : "Collapse")
})
.buttonStyle(PlainButtonStyle())
Color.clear.frame(height: 1000)
}
.padding()
}
}
}

iOS 15 SwiftUI Conditionals on a view with Navigation View makes NavigationBar config to be ignore if navigationViewStyle stack

been searching for this everywhere and can't find anything around this, I believe is a bug, maybe is not.
I need NavigationView with .navigationViewStyle(.stack) to have it stacked on the iPad and make it look the same as the iphone, now suppose you have this view:
import SwiftUI
struct ContentView: View {
#State var isShowingProfile = false
#State var isNavigationViewShowing = true
var body: some View {
if isNavigationViewShowing {
NavigationView {
VStack {
Button("Simple view") {
isNavigationViewShowing = false
}
.padding()
Button("Profile navigation") {
isShowingProfile = true
}
.padding()
NavigationLink(
destination: ProfileView(),
isActive: $isShowingProfile
) {
EmptyView()
}
}
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
)
.background(Color.gray)
.navigationBarHidden(true)
}
.navigationViewStyle(.stack)
} else {
VStack {
Button("Show NavigationView"){
isNavigationViewShowing = true
}
.padding()
}
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
).background(Color.yellow)
}
}
}
struct ProfileView: View {
var body: some View {
Text("This is a profile")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Well this just show this 3 simple views:
The navigationView when you start
The Profile view if you tap on "Profile navigation"
Finally the Simple view which is trigger by the conditional state pressing "Simple view"
Up to here is all fine and good.
The problem is when navigate to the "Simple view" and then tap "Show NavigationView" to navigate back to the NavigtionView.
The app opens the first view (NavigationView), but the NavigationView ignores the .navigationBarHidden(true) and just show a big empty space on the top. In fact, it would ignore things like .navigationBarTitleDisplayMode(.inline) and just show the large version of the navigationBar
This is working correctly in all iOS 14.x, but on iOS 15.0 seems broken. The behaviour continues to be the same on iOS 15.1 beta.
Any idea whats going on? I'm not really interested in changing the conditionals on the view, because real life app is more complex.
Also, I tried ViewBuilder without any success. And if I take out .navigationViewStyle(.stack) it works all fine on iOS 15, but then the view on the iPad is with the side menu.
Thanks a lot for any tip or help, you should be able to reproduce in simulator and real device.
Video of the explained above
I think the better solution all around is to not have the NavigationView be conditional. There is no reason your conditional can't just live in the NavigationView. You just don't ever want the bar to show. Therefore, this code would seem to meet the requirements:
struct ContentView: View {
#State var isShowingProfile = false
#State var isNavigationViewShowing = true
var body: some View {
NavigationView {
Group {
if isNavigationViewShowing {
VStack {
Button("Simple view") {
isNavigationViewShowing = false
}
.padding()
Button("Profile navigation") {
isShowingProfile = true
}
.padding()
NavigationLink(
destination: ProfileView(),
isActive: $isShowingProfile
) {
EmptyView()
}
}
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
)
.background(Color(UIColor.systemGray6))
} else {
VStack {
Button("Show NavigationView"){
isNavigationViewShowing = true
}
.padding()
}
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
).background(Color.yellow)
}
}
.navigationBarHidden(true)
}
.navigationViewStyle(.stack)
}
}
I used Group simply to put the .navigationBarHidden(true) in the correct place so the code would compile.
Is this the behavior you are looking for?
import SwiftUI
struct ContentView: View {
#State private var isShowingProfile = false
#State private var showSimple = false
var body: some View {
NavigationView {
VStack {
Button("Simple view") {
showSimple = true
}
.padding()
Button("Profile navigation") {
isShowingProfile = true
}
.padding()
NavigationLink(destination: ProfileView(), isActive: $isShowingProfile) {
EmptyView()
}
}
.fullScreenCover(isPresented: $showSimple, onDismiss: {
print("Dismissed")
}, content: {
SimpleView()
})
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
)
.background(Color.gray)
.navigationBarHidden(true)
}
.navigationViewStyle(.stack)
}
}
struct ProfileView: View {
var body: some View {
Text("This is a profile")
}
}
struct SimpleView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Button("Show NavigationView") {
presentationMode.wrappedValue.dismiss()
}
.padding()
}
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
).background(Color.yellow)
}
}