SwiftUI - update target view during a transition - ios

I want to transition between two views in SwiftUI using a horizontal sliding transition. The problem is, that I also want to update the target view once the data is fetched from the network.
Down below is a minimal example of the transition. When the button on the first view is pressed, the transition and the (placeholder) background work is started. For better visibility, the transition is slowed down. In the second view, I have a ProgressView which should be replaced with the actual view (here a Text view) once the data is available.
#main
struct MyApp: App {
#StateObject private var viewModel = ViewModel()
#State private var push = false
private let transition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
private let transitionAnimation = Animation.easeOut(duration: 3)
var body: some Scene {
WindowGroup {
if !push {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
Button(action: {
push.toggle()
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) {
DispatchQueue.main.sync {
self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
}
}
}){
Text("Go")
}
Spacer()
}
.background(Color.green)
.transition(transition)
.animation(transitionAnimation)
} else {
SecondView()
.transition(transition)
.animation(transitionAnimation)
.environmentObject(viewModel)
}
}
}
}
final class ViewModel: NSObject, ObservableObject {
#Published var someText: String = ""
}
struct SecondView: View {
#EnvironmentObject var viewModel: ViewModel
var body: some View {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
if(viewModel.someText.isEmpty) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
Text(viewModel.someText)
}
Spacer()
}.background(Color.red)
}
}
The problem now is that the Text view is not included in the view transition. I would expect that it is moving along with the transition (= the red area), but instead, it just appears at the location where it would be after the transition. The following animation shows this effect.
Is it possible to achieve the animation of the Text view? To be clear: I know that in this case I could just always display the Text view because the string is empty at the beginning. As I stated earlier, this is a massively simplified version of my actual view hierarchy. I don't see a way of leaving out the if-else statement or use the hidden modifier.

Few things have to be done to make it work properly.
First, Text should exist in the views hierarchy even when the someText is empty. You can wrap it and the progress indicator into ZStack and control the text visibility with .opacity instead of the if/else statement.
Second, the animations can be applied conditionally depending on which value has changed. You should apply transitionAnimation only when the push variable is changed:
.animation(transitionAnimation, value: push)
There is a catch though: it won't work from the if/else branches on the same variable, because each of the .animation statements will exist only for one value of the push variable, and the changes in it won't be noticed. To fix that if/else should be wrapped into a Group, and animation should be applied to it.
Here is a full solution:
#main
struct MyApp: App {
#StateObject private var viewModel = ViewModel()
#State private var push = false
private let transition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
private let transitionAnimation = Animation.easeOut(duration: 3)
var body: some Scene {
WindowGroup {
Group {
if !push {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
Button(action: {
push.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
}
}){
Text("Go")
}
Spacer()
}
.background(Color.green)
.transition(transition)
} else {
SecondView()
.transition(transition)
.environmentObject(viewModel)
}
}
.animation(transitionAnimation, value: push)
}
}
}
final class ViewModel: NSObject, ObservableObject {
#Published var someText: String = ""
}
struct SecondView: View {
#EnvironmentObject var viewModel: ViewModel
var body: some View {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
ZStack {
if(viewModel.someText.isEmpty) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
Text(viewModel.someText)
.opacity(viewModel.someText.isEmpty ? 0.0 : 1.0)
}
Spacer()
}.background(Color.red)
}
}

Related

SwiftUI increasing sheet size after NavigationLink with the "selection" in "presentationDetents" cuts off view inside the sheet

Since iOS 16 there is a new feature for the ".sheet" modifier called ".presentationDetents". ".presentationDetents" has a parameter called "selection" where you can pass a Binding. You can programmatically resize the sheet with the "selection" parameter. As soon as you change the sheet size for example from PresentationDetent.medium to PresentationDetent.large right after changed the page with a "NavigationLink" the View below gets cut off:
But if I slightly move (resize) the sheet afterwards the cut off below is going to disappear:
The view hierarchy is also strange:
If you add a delay by 0.6s for resizing the sheet, the cut off won't happen.
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
currentSelection = .large
}
}
You can find the code below:
import SwiftUI
struct ContentView: View {
#State private var sheetIsOpened = false
#State private var currentSelection = PresentationDetent.medium
var body: some View {
Text("Click to open a sheet")
.padding()
.onTapGesture {
sheetIsOpened = true
}
.sheet(isPresented: $sheetIsOpened) {
NavigationStack {
List {
ScrollView {
ForEach(0..<100) { index in
VStack {
NavigationLink(destination: NavigatedView(currentSelection: $currentSelection)) {
Text("I have the index: \(index)")
.foregroundColor(.green)
}
}
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
.presentationDetents([.medium, .large], selection: $currentSelection)
}
}
}
struct NavigatedView: View {
#Binding fileprivate var currentSelection: PresentationDetent
var body: some View {
ScrollView {
ForEach(0..<100) { index in
VStack {
Text("I'm a child and I have the index: \(index)")
.onAppear {
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
currentSelection = .large
// }
}
}
.frame(maxWidth: .infinity)
}
}
.background(.red)
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

SwiftUI Transition not happening

I am new to SwiftUI and I am trying to use the .transition, but for some reason no transition happens.
You can see the code below:
View
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
Button {
self.viewModel.show()
} label: {
Text("Tap to change")
}
}
}
ViewModel
class ViewModel: ObservableObject {
#Published private(set) var model = Model()
func show() {
self.model.toggleShow()
}
}
Model
struct Model {
var show: Bool = true
mutating func toggleShow() {
self.show.toggle()
}
}
When I tap the button the text changes but no transition occurs.
I feel like I am missing something trivial here.
Can anyone please assist?
You need an animation (to animate transition) and a container (which performs actual transition, because default implicit Group does not do that).
Here is fixed part of code (tested with Xcode 13.2 / iOS 15.2)
*Note:Preview > Debug > Slow Animation for better visibility
var body: some View {
VStack { // << this !!
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
}
.animation(.default, value: self.viewModel.model.show) // << here !!
Button {
self.viewModel.show()
} label: {
Text("Tap to change")
}
}
Your code is fine (besides the fact that you need a VStack wrapping the text and the button), you only need to tell SwiftUI to use the transition by wrapping the command inside withAnimation().
Here's what you simply need to do in ContentView (look at the Button):
#ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
Button {
withAnimation { // This is what you need to trigger the transition
self.viewModel.show()
}
} label: {
Text("Tap to change")
}
}
.animation(.easeIn, value: self.viewModel.show)
}

Action based on a variable update in SwiftUI

I have the following kind of code in a SwiftUI app.
And I want to perform a certain action (which is going to update my UI) when the state variable answerFlag has been touched (in the CustomButton view).
I would like to get some hints on the way to go for that.
My SwiftUI knowledge is still too limited or I do not yet master the use of what I know. The couple of things I tried did not work.
struct ContentView: View {
#State var answerFlag:Bool = false
......
var body: some View {
VStack {
Spacer()
CustomButton(text: answerText,
validAnswer: correctAnswer,
replyFlag: $answerFlag)
Spacer()
}.background(theBGColor)
.onTapGesture {
... do some useful thing ...
}
// I need to perform some action when answerFlag has changed !!??
}
}
struct CustomButton: View {
var text,validAnswer: String
#Binding var replyFlag:Bool
var body: some View {
Text(text)
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.primary)
.padding(.horizontal, 6.0)
.background(Color.brown)
.cornerRadius(5.0)
.overlay(RoundedRectangle(cornerRadius: 5.0)
.stroke(lineWidth: 2.0)
.foregroundColor(Color.gray))
.onTapGesture {
print("BUTTON-HIT:\(self.text)::\(self.validAnswer)")
if self.text == self.validAnswer {print("OK")}
else {print("NG")}
self.replyFlag = true
}
}
}
Here is possible way...
SwiftUI 2.0
var body: some View {
VStack {
Spacer()
CustomButton(text: answerText,
validAnswer: correctAnswer,
replyFlag: $answerFlag)
Spacer()
}.background(theBGColor)
.onTapGesture {
... do some useful thing ...
}
// I need to perform some action when answerFlag has changed !!??
.onChange(of: answerFlag) { newValue in
// ... do anything heeded here
}
}
SwiftUI 1.0+
Similarly to above add to some view the following
import Combine
...
.onReceive(Just(answerFlag)) { newValue in
// ... do anything heeded here
}

Why SwiftUI context menu show all row view in preview?

I have a complex view in List row:
var body: some View {
VStack {
VStack {
FullWidthImageView(ad)
HStack {
Text("\(self.price) \(self.ad.currency!)")
.font(.headline)
Spacer()
SwiftUI.Image(systemName: "heart")
}
.padding([.top, .leading, .trailing], 10.0)
Where FullWidthImageView is view with defined contexMenu modifier.
But when I long-press on an image I see not the only image in preview, but all row view.
There is no other contextMenu on any element.
How to make a preview in context with image only?
UPD. Here is a simple code illustrating the problem
We don't have any idea why in your case it doesn't work, until we see your FullWidthImageView and how you construct the context menu. Asperi's answer is working example, and it is correctly done! But did it really explain your trouble?
The trouble is that while applying .contextMenu modifier to only some part of your View (as in your example) we have to be careful.
Let see some example.
import SwiftUI
struct FullWidthImageView: View {
#ObservedObject var model = modelStore
var body: some View {
VStack {
Image(systemName: model.toggle ? "pencil.and.outline" : "trash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200)
}.contextMenu(ContextMenu {
Button(action: {
self.model.toggle.toggle()
}) {
HStack {
Text("toggle image to?")
Image(systemName: model.toggle ? "trash" : "pencil.and.outline")
}
}
Button("No") {}
})
}
}
class Model:ObservableObject {
#Published var toggle = false
}
let modelStore = Model()
struct ContentView: View {
#ObservedObject var model = modelStore
var body: some View {
VStack {
FullWidthImageView()
Text("Long press the image to change it").bold()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
while running, the "context menu" modified View seems to be "static"!
Yes, on long press, you see the trash image, even though it is updated properly while you dismiss the context view. On every long press you see trash only!
How to make it dynamic? I need that the image will be the same, as on my "main View!
Here we have .id modifier. Let see the difference!
First we have to update our model
class Model:ObservableObject {
#Published var toggle = false
var id: UUID {
UUID()
}
}
and next our View
FullWidthImageView().id(model.id)
Now it works as we expected.
For another example, where "standard" state / binding simply doesn't work check SwiftUI hierarchical Picker with dynamic data crashes
UPDATE
As a temporary workaround you can mimic List by ScrollView
import SwiftUI
struct Row: View {
let i:Int
var body: some View {
VStack {
Image(systemName: "trash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200)
.contextMenu(ContextMenu {
Button("A") {}
Button("B") {}
})
Text("I don’t want to show in preview because I don’t have context menu modifire").bold()
}.padding()
}
}
struct ContentView: View {
var body: some View {
VStack {
ScrollView {
ForEach(0 ..< 20) { (i) in
VStack {
Divider()
Row(i: i)
}
}
}
}
}
}
It is not optimal, but in your case it should work
Here is a code (simulated possible your scenario) that works, ie. only image is shown for context menu preview (tested with Xcode 11.3+).
struct FullWidthImageView: View {
var body: some View {
Image("auto")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200)
.contextMenu(ContextMenu() {
Button("Ok") {}
})
}
}
struct TestContextMenu: View {
var body: some View {
VStack {
VStack {
FullWidthImageView()
HStack {
Text("100 $")
.font(.headline)
Spacer()
Image(systemName: "heart")
}
.padding([.top, .leading, .trailing], 10.0)
}
}
}
}
It's buried in the replies here, but the key discovery is that List is changing the behavior of .contextMenu -- it creates "blocks" that pop up with the menu instead of attaching the menu to the element specified. Switching out List for ScrollView fixes the issue.

iOS SwiftUI: pop or dismiss view programmatically

I couldn't find any reference about any ways to make a pop or a dismiss programmatically of my presented view with SwiftUI.
Seems to me that the only way is to use the already integrated slide dow action for the modal(and what/how if I want to disable this feature?), and the back button for the navigation stack.
Does anyone know a solution?
Do you know if this is a bug or it will stays like this?
This example uses the new environment var documented in the Beta 5 Release Notes, which was using a value property. It was changed in a later beta to use a wrappedValue property. This example is now current for the GM version. This exact same concept works to dismiss Modal views presented with the .sheet modifier.
import SwiftUI
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
Button(
"Here is Detail View. Tap to go back.",
action: { self.presentationMode.wrappedValue.dismiss() }
)
}
}
struct RootView: View {
var body: some View {
VStack {
NavigationLink(destination: DetailView())
{ Text("I am Root. Tap for Detail View.") }
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
RootView()
}
}
}
SwiftUI Xcode Beta 5
First, declare the #Environment which has a dismiss method which you can use anywhere to dismiss the view.
import SwiftUI
struct GameView: View {
#Environment(\.presentationMode) var presentation
var body: some View {
Button("Done") {
self.presentation.wrappedValue.dismiss()
}
}
}
iOS 15+
Starting from iOS 15 we can use a new #Environment(\.dismiss):
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
Text("Sheet")
.toolbar {
Button("Done") {
dismiss()
}
}
}
}
}
(There's no more need to use presentationMode.wrappedValue.dismiss().)
Useful links:
DismissAction
There is now a way to programmatically pop in a NavigationView, if you would like. This is in beta 5. Notice that you don't need the back button. You could programmatically trigger the showSelf property in the DetailView any way you like. And you don't have to display the "Push" text in the master. That could be an EmptyView(), thereby creating an invisible segue.
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
MasterView()
}
}
}
struct MasterView: View {
#State private var showDetail = false
var body: some View {
VStack {
NavigationLink(destination: DetailView(showSelf: $showDetail), isActive: $showDetail) {
Text("Push")
}
}
}
}
struct DetailView: View {
#Binding var showSelf: Bool
var body: some View {
Button(action: {
self.showSelf = false
}) {
Text("Pop")
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
I recently created an open source project called swiftui-navigation-stack (https://github.com/biobeats/swiftui-navigation-stack) that contains the NavigationStackView, an alternative navigation stack for SwiftUI. It offers several features described in the readme of the repo. For example, you can easily push and pop views programmatically. I'll show you how to do that with a simple example:
First of all embed your hierarchy in a NavigationStackVew:
struct RootView: View {
var body: some View {
NavigationStackView {
View1()
}
}
}
NavigationStackView gives your hierarchy access to a useful environment object called NavigationStack. You can use it to, for instance, pop views programmatically as asked in the question above:
struct View1: View {
var body: some View {
ZStack {
Color.yellow.edgesIgnoringSafeArea(.all)
VStack {
Text("VIEW 1")
Spacer()
PushView(destination: View2()) {
Text("PUSH TO VIEW 2")
}
}
}
}
}
struct View2: View {
#EnvironmentObject var navStack: NavigationStack
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.all)
VStack {
Text("VIEW 2")
Spacer()
Button(action: {
self.navStack.pop()
}, label: {
Text("PROGRAMMATICALLY POP TO VIEW 1")
})
}
}
}
}
In this example I use the PushView to trigger the push navigation with a tap. Then, in the View2 I use the environment object to programmatically come back.
Here is the complete example:
import SwiftUI
import NavigationStack
struct RootView: View {
var body: some View {
NavigationStackView {
View1()
}
}
}
struct View1: View {
var body: some View {
ZStack {
Color.yellow.edgesIgnoringSafeArea(.all)
VStack {
Text("VIEW 1")
Spacer()
PushView(destination: View2()) {
Text("PUSH TO VIEW 2")
}
}
}
}
}
struct View2: View {
#EnvironmentObject var navStack: NavigationStack
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.all)
VStack {
Text("VIEW 2")
Spacer()
Button(action: {
self.navStack.pop()
}, label: {
Text("PROGRAMMATICALLY POP TO VIEW 1")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
RootView()
}
}
the result is:
Alternatively, if you don't want to do it programatically from a button, you can emit from the view model whenever you need to pop.
Subscribe to a #Published that changes the value whenever the saving is done.
struct ContentView: View {
#ObservedObject var viewModel: ContentViewModel
#Environment(\.presentationMode) var presentationMode
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
.textContentType(.name)
}
.onAppear {
self.viewModel.cancellable = self.viewModel
.$saved
.sink(receiveValue: { saved in
guard saved else { return }
self.presentationMode.wrappedValue.dismiss()
}
)
}
}
}
class ContentViewModel: ObservableObject {
#Published var saved = false // This can store any value.
#Published var name = ""
var cancellable: AnyCancellable? // You can use a cancellable set if you have multiple observers.
func onSave() {
// Do the save.
// Emit the new value.
saved = true
}
}
Please check Following Code it's so simple.
FirstView
struct StartUpVC: View {
#State var selection: Int? = nil
var body: some View {
NavigationView{
NavigationLink(destination: LoginView().hiddenNavigationBarStyle(), tag: 1, selection: $selection) {
Button(action: {
print("Signup tapped")
self.selection = 1
}) {
HStack {
Spacer()
Text("Sign up")
Spacer()
}
}
}
}
}
SecondView
struct LoginView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView{
Button(action: {
print("Login tapped")
self.presentationMode.wrappedValue.dismiss()
}) {
HStack {
Image("Back")
.resizable()
.frame(width: 20, height: 20)
.padding(.leading, 20)
}
}
}
}
}
You can try using a custom view and a Transition.
Here's a custom modal.
struct ModalView<Content>: View where Content: View {
#Binding var isShowing: Bool
var content: () -> Content
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .center) {
if (!self.isShowing) {
self.content()
}
if (self.isShowing) {
self.content()
.disabled(true)
.blur(radius: 3)
VStack {
Text("Modal")
}
.frame(width: geometry.size.width / 2,
height: geometry.size.height / 5)
.background(Color.secondary.colorInvert())
.foregroundColor(Color.primary)
.cornerRadius(20)
.transition(.moveAndFade) // associated transition to the modal view
}
}
}
}
}
I reused the Transition.moveAndFade from the Animation Views and Transition tutorial.
It is defined like this:
extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale()
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
You can test it - in the simulator, not in the preview - like this:
struct ContentView: View {
#State var isShowingModal: Bool = false
func toggleModal() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
self.isShowingModal = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
self.isShowingModal = false
}
}
}
}
var body: some View {
ModalView(isShowing: $isShowingModal) {
NavigationView {
List(["1", "2", "3", "4", "5"].identified(by: \.self)) { row in
Text(row)
}.navigationBarTitle(Text("A List"), displayMode: .large)
}.onAppear { self.toggleModal() }
}
}
}
Thanks to that transition, you will see the modal sliding in from the trailing edge, and the it will zoom and fade out when it is dismissed.
The core concept of SwiftUI is to watch over the data flow.
You have to use a #State variable and mutate the value of this variable to control popping and dismissal.
struct MyView: View {
#State
var showsUp = false
var body: some View {
Button(action: { self.showsUp.toggle() }) {
Text("Pop")
}
.presentation(
showsUp ? Modal(
Button(action: { self.showsUp.toggle() }) {
Text("Dismiss")
}
) : nil
)
}
}
I experienced a compiler issue trying to call value on the presentationMode binding. Changing the property to wrappedValue fixed the issue for me. I'm assuming value -> wrappedValue is a language update. I think this note would be more appropriate as a comment on Chuck H's answer but don't have enough rep points to comment, I also suggested this change as and edit but my edit was rejected as being more appropriate as a comment or answer.
This will also dismiss the view
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
let window = windowScene?.windows.first
window?.rootViewController?.dismiss(animated: true, completion: {
print("dismissed")
})

Resources