Can't get removal of view to animate - ios

I have a VStack where I have a title above some other views. The title is shown/hidden based on the value of an #Published variable from an environment object. Ideally, when the title should be hidden, I want it to transition by fading out and moving the views below it in the VStack up. However, what actually happens is it just disappears immediately and moves the rest of the views up without animation. How can I fix this?
struct MyView: View {
#EnvironmentObject var modelController: MyModelController
var body: some View {
VStack {
title()
//..other views here
}
}
#ViewBuilder
func title() -> some View {
if let currentPage = modelController.currentPage,
currentPage >= 6 {
EmptyView()
} else {
Text("Create Event")
}
}
}

You'll need to use the .transition() modifier to tell the system that you want to animate when the view appears or disappears. Additionally, I don't think you need to return EmptyView when you want to hide the title.
#ViewBuilder
func title() -> some View {
if modelController.currentPage ?? 0 < 6 {
Text("Create Event")
.transition(.opacity)
}
}
I've used the opacity transition but it's a very customizable modifier, and you can pass an asymmetric transition that does different animations on insert and removal. I would suggest googling it or looking at the documentation to learn more about it.

Your code snapshot is not testable, but try the following
VStack {
title()
//..other views here
}
.animation(.default, value: modelController.currentPage) // << here !!

Related

Conditionally wrap code inside view SwiftUI

I want to make my code inside a scrollview based on a boolean
Something like this :
ScrollView{
Vstack {
}
Hstack {
}
}.isWrapped(true)
So if the bool is true, the content is wrapped inside a ScrollView, if not, I just get the content as is. (without the scrollView parent)
I tried with multiples way, the only thing I see as possible is 2 blocks of code, not good practice thought.
One way is to always use a ScrollView, but tell it not to scroll. If you pass an empty set as the first argument to ScrollView, it won't scroll.
ScrollView(shouldScroll ? .vertical : []) {
// content here
}
Another way is to extract the content back out of the ScrollView in your proposed isWrapped modifier, like this:
extension ScrollView {
#ViewBuilder
func isWrapped(_ flag: Bool) -> some View {
if flag { self }
else { self.content }
}
}
Because of the nature of SwiftUI, you can't remove the parent without removing children. As you mentioned in your edit, this will require two blocks of code. But, you can make it much more friendly in two different ways.
The first way is a reusable component. It takes a Binding isVisible variable and the content.
struct ConditionalScrollView<Content: View>: View {
#Binding private var isVisible: Bool
private var builtContent: Content
init(isVisible: Binding<Bool>, content: () -> Content) {
self._isVisible = isVisible
builtContent = content()
}
var body: some View {
if isVisible {
ScrollView { builtContent }
} else {
builtContent
}
}
}
To use this new component is to simply replace the use of ScrollView in the area that you want to manually adjust. I.E:
struct ContentView: View {
#State var isVisible = true
var body: some View {
ConditionalScrollView(isVisible: $isVisible) {
Text("Hello, world!")
.padding()
}
}
}
But this is not your only option.
If you want to keep things as simple as possible, you can achieve this using a simple ViewBuilder. Create the view you don't want to change inside the ViewBuilder, and then create a condition around the ScrollView, all the while placing the variable as the content. It would look as follows:
struct ContentView: View {
#State private var isVisible = true
#ViewBuilder private var mainContent: some View {
Text("Hello, world!")
.padding()
}
var body: some View {
if isVisible {
ScrollView { mainContent }
} else {
mainContent
}
}
}
As you've probably come to realize, this is one of the limitations of SwiftUI. But it can be considered a strength because you will always know if a view's parent is there or not.
I hope this little tidbit helped, happy coding!
You can also make it even easier:
#State var isWraped : bool = true
var body: some View {
if isWrapped {
ScrollView {
YourView()
}
else {
YourView()
}

SwiftUI make ForEach List row properly clickable for edition in EditMode

Description
I have this following code:
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: someView()) {
someRowDisplayView()
} //: NavigationLink
} //: ForEach
} //: List
} //: NavigationView
Basically, it displays a dynamic list of class objects previously filled by user input. I have an "addToList()" function that allows users to add some elements (let's say name, email, whatever) and once the user confirms it adds the element to this list. Then I have a view that displays the list through a ForEach and then makes a NavigationLink of each element as indicated in the code example above.
Once clicked on an element of this list, the user should be properly redirected to a destination view as the NavigationLink implies. All this is working fine.
What I want
Here's where I face an issue: I want the user to be able to edit the content of a row of this list without having to delete the row and re-add another one.
I decided to use the EditMode() feature of SwiftUI. So this is what I came up with:
#State private var editMode = EditMode.inactive
[...]
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: someView()) {
someRowDisplayView()
} //: NavigationLink
} //: ForEach
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
} //: NavigationView
The EditMode is properly triggered when I click on the Edit button. But I noticed that the list row is not clickable in edit mode, which is fine because I do not want it to follow the NavigationLink while in edit mode.
Though what I want is that the user is either redirected to a view that allows editing of the tapped row, or better, that an edition sheet is presented to the user.
What I have tried
Since I couldn't tap the row in edition mode, I have tried several tricks but none of them concluded as I wanted. Here are my tries.
.simultaneousGesture
I tried to add a .simultaneousGesture modified to my NavigationLink and toggle a #State variable in order to display the edition sheet.
#State private var isShowingEdit: Bool = false
[...]
.simultaneousGesture(TapGesture().onEnded{
isShowingEdit = true
})
.sheet(isPresented: $isShowingEdit) {
EditionView()
}
This simply does not work. It seems that this .simultaneousGesture somehow breaks the NavigationLink, the tap succeeds like once out of five times.
It doesn't work even by adding a condition on the edit mode, like:
if (editMode == .active) {
isShowingEdit = true
}
In non-edition mode the NavigationLink is still bugged. But I have noticed that once in edition mode, it kind of does what I wanted.
Modifier condition extension on View
After the previous failure my first thought was that I needed the tap gesture to be triggered only in edit mode, so that in non-edit mode the view doesn't even know that it has a tap gesture.
I decided to add an extension to the View structure in order to define conditions to modifiers (I found this code sample somewhere on Stackoverflow):
extension View {
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
Then in my main code, instead of the previously tried .simultaneousGesture modifier, I added this to NavigationLink:
.if(editMode == .active) { view in
view.onTapGesture {
isShowingEdit = true
}
}
And it worked, I was able to display this edition view once a user taps on a row while in edition mode and the normal non-edition mode still worked as it properly triggered the NavigationLink.
Remaining issues
But something bothered me, it didn't feel like a natural or native behavior. Once tapped, the row didn't show any feedback like it shows in non-edition mode when following the NavigationLink: it doesn't highlight even for a very short time.
The tap gesture modifier simply executes the code I have asked without animation, feedback or anything.
I would like the user to know and see which row was tapped and I would like it to highlight just as it does when when tapping on the NavigationLink: simply make it look like the row was actually "tapped". I hope it makes sense.
I would also like the code to be triggered when the user taps on the whole row and not only the parts where the text is visible. Currently, tapping on an empty field of the row does nothing. It has to have text on it.
An even better solution would be something that prevents me from applying such conditional modifiers and extensions to the View structure as I surely prefer a more natural and better method if this is possible.
I'm new to Swift so maybe there is a lot easier or better solution and I'm willing to follow the best practices.
How could I manage to accomplish what I want in this situation?
Thank you for your help.
Additional information
I am currently using the .onDelete and .onMove implementations on the List alongside with the SwiftUI edition mode and I want to keep using them.
I am developing the app for minimum iOS 14, using Swift language and SwiftUI framework.
If you need more code samples or better explanations please feel free to ask and I will edit my question.
Minimal working example
Asked by Yrb.
import SwiftUI
import PlaygroundSupport
extension View {
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
struct SomeList: Identifiable {
let id: UUID = UUID()
var name: String
}
let someList: [SomeList] = [
SomeList(name: "row1"),
SomeList(name: "row2"),
SomeList(name: "row3")
]
struct ContentView: View {
#State private var editMode = EditMode.inactive
#State private var isShowingEditionSheet: Bool = false
var body: some View {
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: EmptyView()) {
Text(someElement.name)
} //: NavigationLink
.if(editMode == .active) { view in
view.onTapGesture {
isShowingEditionSheet = true
}
}
} //: ForEach
.onDelete { (indexSet) in }
.onMove { (source, destination) in }
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $isShowingEditionSheet) {
Text("Edition sheet")
}
} //: NavigationView
}
}
PlaygroundPage.current.setLiveView(ContentView())
As we discussed in the comments, your code does everything in your list except highlight the row when the disclosure chevron is tapped. To change the background color of a row, you use .listRowBackground(). To make just one row highlight, you need to make the row identifiable off of the id in your array in the ForEach. You then have an optional #State variable to hold the value of the row id, and set the row id in your .onTapGesture. Lastly, you use a DispatchQueue.main.asyncAfter() to reset the variable to nil after a certain time. This gives you a momentary highlight.
However, once you go this route, you have to manage the background highlighting for your NavigationLink as well. This brings its own complexity. You need to use the NavigationLink(destination:,isActive:,label:) initializer, create a binding with a setter and getter, and in the getter, run your highlight code as well.
struct ContentView: View {
#State private var editMode = EditMode.inactive
#State private var isShowingEditionSheet: Bool = false
#State private var isTapped = false
#State private var highlight: UUID?
var body: some View {
NavigationView {
List {
ForEach(someList) { someElement in
// You use the NavigationLink(destination:,isActive:,label:) initializer
// Then create your own binding for it
NavigationLink(destination: EmptyView(), isActive: Binding<Bool>(
get: { isTapped },
// in the set, you can run other code
set: {
isTapped = $0
// Set highlight to the row you just tapped
highlight = someElement.id
// Reset the row id to nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
highlight = nil
}
}
)) {
Text(someElement.name)
} //: NavigationLink
// identify your row
.id(someElement.id)
.if(editMode == .active) { view in
view.onTapGesture {
// Set highlight to the row you just tapped
highlight = someElement.id
// Reset the row id to nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
highlight = nil
}
isShowingEditionSheet = true
}
}
.listRowBackground(highlight == someElement.id ? Color(UIColor.systemGray5) : Color(UIColor.systemBackground))
} //: ForEach
.onDelete { (indexSet) in }
.onMove { (source, destination) in }
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $isShowingEditionSheet) {
Text("Edition sheet")
}
} //: NavigationView
}
}

How to dismiss a presenting view to the root view of tab view in SwiftUI?

I'm using TabView on my home page. Let's just say I have 4 tabs.
On second tab, i can go to another view using NavigationLink and I go to another 2 views using NavigationLink. Then on the latest view, there is a button to present a view and i use .fullScreenCover (since I want to present it full screen).
In the presenting view, I add an X mark on the left side of the navigationBarItems to dismiss. I use #Environment(\.presentationMode) var presentationMode and presentationMode.wrappedValue.dismiss() to dismiss. But it only dismiss the presenting view to the previous view, while actually I want to dismiss it to the root of my view which is the 2nd tab of my TabView.
Is there a way to do this? Because I have looked up to some articles and nothing relevant especially in TabView context.
I also have a question tho:
Is it a right approach to use .fullScreenCover? Or is there another possible solution for example presenting a modal with full screen style (if there's any cause i'm not sure either).
Any suggestions will be very appreciated, thankyou in advance.
The presentationMode is one-level effect value, ie changing it you close one currently presented screen.
Thus to close many presented screens you have to implement this programmatically, like in demo below.
The possible approach is to use custom EnvironmentKey to pass it down view hierarchy w/o tight coupling of every level view (like with binding) and inject/call only at that level where needed.
Demo tested with Xcode 12.4 / iOS 14.4
struct ContentView: View {
var body: some View {
TabView {
Text("Tab1")
.tabItem { Image(systemName: "1.square") }
Tab2RootView()
.tabItem { Image(systemName: "2.square") }
}
}
}
struct Tab2RootView: View {
#State var toRoot = false
var body: some View {
NavigationView {
Tab2NoteView(level: 0)
.id(toRoot) // << reset to root !!
}
.environment(\.rewind, $toRoot) // << inject here !!
}
}
struct Tab2NoteView: View {
#Environment(\.rewind) var rewind
let level: Int
#State private var showFullScreen = false
var body: some View {
VStack {
Text(level == 0 ? "ROOT" : "Level \(level)")
NavigationLink("Go Next", destination: Tab2NoteView(level: level + 1))
Divider()
Button("Full Screen") { showFullScreen.toggle() }
.fullScreenCover(isPresented: $showFullScreen,
onDismiss: { rewind.wrappedValue.toggle() }) {
Tab2FullScreenView()
}
}
}
}
struct RewindKey: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
var rewind: Binding<Bool> {
get { self[RewindKey.self] }
set { self[RewindKey.self] = newValue }
}
}
struct Tab2FullScreenView: View {
#Environment(\.presentationMode) var mode
var body: some View {
Button("Close") { mode.wrappedValue.dismiss() }
}
}
You have 2 options:
With .fullScreenCover you will have a binding that results in it being presented you can pass this binding through to the content and when the user taps on x set to to false
You can use the #Environment(\.presentationMode) var presentationMode then call presentationMode.wrappedValue.dismiss() in your button body.
Edit:
If you want to unwind all the way you should make the TabView be binding based. I like to use SceneStorage for this take a look at this post then you can access this SceneStorage value anywhere in your app to respond to it but also to update and change the navigation (this also has the benefit of providing you proper state restoration!)
If you make your TabView in this way:
struct ContentView: View {
#SceneStorage("selectedTab") var selectedTab: Tab = .car
var body: some View {
TabView(selection: $selectedTab) {
CarTrips()
.tabItem {
Image(systemName: "car")
Text("Car Trips")
}.tag(Tab.car)
TramTrips()
.tabItem {
Image(systemName: "tram.fill")
Text("Tram Trips")
}.tag(Tab.tram)
AirplaneTrips()
.tabItem {
Image(systemName: "airplane")
Text("Airplane Trips")
}.tag(Tab.airplaine)
}
}
}
enum Tab: String {
case car
case tram
case airplaine
}
Then deep within your app in the place you want to change the navigation you can create a button view.
struct ViewCarButton: View {
#SceneStorage("selectedTab") var selectedTab: Tab = .car
var body: some View {
Button("A Button") {
selectedTab = .car
}
}
}
This will forced the selected tab to be the car tab.
if instead of this you do not want to change tab but rather change what the navigation view is navigated to you can use the same concept for that, NavigationLink that's a binding if this binding is created using a #SceneStorage then in your ViewCarButton you can make changes to it that will change the navigation state.

SwiftUI: attach an animation to a transition

According to the Apple documentation we should be able to attach an animation directly to a transition. For example:
.transition(AnyTransition.slide.animation(.linear))
documentation for the method:
extension AnyTransition {
/// Attach an animation to this transition.
public func animation(_ animation: Animation?) -> AnyTransition
}
says:
Summary
Attaches an animation to this transition.
But I can't manage to make it work. Take a look at this minimum viable example (you can copy-paste it and try yourself):
import SwiftUI
struct AnimationTest: View {
#State private var show = false
var body: some View {
VStack {
if show {
Color.green
.transition(AnyTransition.slide.animation(.linear))
} else {
Color.yellow
.transition(AnyTransition.slide.animation(.linear))
}
Button(action: {
self.show.toggle()
}, label: {
Text("CHANGE VIEW!")
})
}
}
}
struct AnimationTest_Previews: PreviewProvider {
static var previews: some View {
AnimationTest()
}
}
As you can see no animation happens at all. Any ideas? Thank you.
You need to wrap the boolean toggling within a withAnimation() closure:
withAnimation {
self.show.toggle()
}
Tested the transition working on Xcode 11.2.1 while on the simulator; the Canvas doesn't preview it.
Please note that animations/transitions applied directly to a view have an effect on that particular view and its children. Moreover, according to the docs:
func animation(Animation?) -> View
Applies the given animation to all animatable values within the view.
Since the animatable value, in this case the Bool toggle enabling the transition, is external to the Color views, it must be animated explicitly from where it's set in the button's action. Alternatively, one can effectively attach the transition directly to the target views, but apply the animation to their container, thus enabling interpolation of changes to show. So, this also achieves the desired result:
struct AnimationTest: View {
#State private var show = false
var body: some View {
VStack {
if show {
Color.green
.transition(.slide)
} else {
Color.yellow
.transition(.slide)
}
Button(action: {
self.show.toggle()
}, label: {
Text("CHANGE VIEW!")
})
}
.animation(.linear)
}
}

SwiftUI add subview dynamically but the animation doesn't work

I would like to create a view in SwiftUI that add a subview dynamically and with animation.
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
if isButtonVisible {
AnyView(DetailView())
.transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}else{
AnyView(Text("test"))
}
}
}
}
The above code works fine with the animation . however when i move the view selection part into a function, the animation is not working anymore (since i want to add different views dynamically, therefore, I put the logic in a function.)
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
subView().transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}
func subView() -> some View {
if isButtonVisible {
return AnyView(DetailView())
}else{
return AnyView(Text("test"))
}
}
}
it looks totally the same to me, however, i don't understand why they have different result. Could somebody explain me why? and any better solutions? thanks alot!
Here's your code, modified so that it works:
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
subView()
.transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}
}
func subView() -> some View {
Group {
if isButtonVisible {
DetailView()
} else {
Text("test")
}
}
}
}
Note two things:
Your two examples above are different, which is why you get different results. The first applies a transition and animation to a DetailView, then type-erases it with AnyView. The second type-erases a DetailView with AnyView, then applies a transition and animation.
Rather that using AnyView and type-erasure, I prefer to encapsulate the conditional logic inside of a Group view. Then the type you return is Group, which will animate properly.
If you wanted different animations on the two possibilities for your subview, you can now apply them directly to DetailView() or Text("test").
Update
The Group method will only work with if, elseif, and else statements. If you want to use a switch, you will have to wrap each branch in AnyView(). However, this breaks transitions/animations. Using switch and setting custom animations is currently not possible.
I was able to get it to work with a switch statement by wrapping the function that returns an AnyView in a VStack. I also had to give the AnyView an .id so SwiftUI can know when it changes. This is on Xcode 11.3 and iOS 13.3
struct EnumView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
view(for: viewModel.viewState)
.id(viewModel.viewState)
.transition(.opacity)
}
}
func view(for viewState: ViewModel.ViewState) -> AnyView {
switch viewState {
case .loading:
return AnyView(LoadingStateView(viewModel: self.viewModel))
case .dataLoaded:
return AnyView(LoadedStateView(viewModel: self.viewModel))
case let .error(error):
return AnyView(ErrorView(error: error, viewModel: viewModel))
}
}
}
Also for my example in the ViewModel I need to wrap the viewState changes in a withAnimation block
withAnimation {
self.viewState = .loading
}
In iOS 14 they added the possibility to use if let and switch statements in function builders. Maybe it helps for your issues:
https://www.hackingwithswift.com/articles/221/whats-new-in-swiftui-for-ios-14 (at the article's bottom)

Resources