I'm learning SwiftUI and having trouble with closing Each Tab View Element.
My App shows photos from user's album by TabView with pageViewStyle one by one.
And What I want to make is user can click save button in each view, and when button is clicked, save that photo and close only that view while other photos are still displayed. So unless all photos are saved or discarded, if user clicks save button, TabView should automatically move to another one.
However, I don't know how to close only one Tab Element. I've tried to use dismiss() and dynamically changing vm.images element. Latter one actually works, but it displays awkward movement and it also requires quite messy code. How could I solve this issue?
Here is my code.
TabView {
ForEach(vm.images, id: \.self) { image in
TestView(image: image)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
struct TestView: View {
#ObservedObject var vm: TestviewModel
...
var body: some View {
VStack(spacing: 10) {
Image(...)
Spacer()
Button {
...
} label: {
Text("Save")
}
}
You need actually to remove saved image from the viewModel container, and UI will be updated automatically
literally
Button {
vm.images.removeAll { $0.id == image.id } // << here !!
} label: {
Text("Save")
}
You need to use the selection initializer of TabView in order to control what it displays. So replace TabView with:
TabView(selection: $selection)
Than add a new property: #State var selection: YourIdType = someDefaultValue, and in the Button action you set selection to whatever you want to display.
Also add .tag(TheIdTheViewWillUse) remember that whatever Id you use must be the same as your selection variable. I recommend you use Int for the simple use.
Related
I've added onboarding steps to my app using SwiftUI instead of a UIPageViewController. The root-level view has several steps:
struct OnboardingView: View {
#EnvironmentObject private var onboardingModel: OnboardingModel
var body: some View {
NavigationView {
VStack {
FirstStep()
NavigationLink(destination: SecondStep(),
tag: "second-step",
selection: $onboardingModel.selectedStep) {
EmptyView()
}
NavigationLink(destination: ThirdStep(),
tag: "third-step",
selection: $onboardingModel.selectedStep) {
EmptyView()
}
// etc.
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
The OnboardingModel keeps track of the selectedStep, so that changes to it will trigger the corresponding NavigationLink's tag.
When the onboarding view starts up, the FirstStep animates in from the right, just like an ordinary push navigation. The remaining steps, however, simply replace the previous step without any animation. Adding an .animation(.easeIn) to each step does nothing. How can I make this look and feel like a UIPageViewController?
I should note that each step hides the navigation bar and title:
.navigationTitle(Text(""))
.navigationBarHidden(true)
but removing these lines doesn't restore any built-in animation that the NavigationView provides.
Click the screencap to see it in action. Note that only the first scene is animated in; the rest just appear, even though that is obscured a bit by the permissions dialogs.
Is there anyway to keep the tab bar showing while presenting a modal / sheet view?
Here is a minimal failing example.
import SwiftUI
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
Button("Press to dismiss") {
dismiss()
}
.padding()
}
}
struct Tab1: View {
#State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
SheetView()
}
}
}
struct MainView: View {
var body: some View {
TabView {
Tab1()
.tabItem {
Label("Tab 1", systemImage: "heart")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}
Thanks for answering my question in the comments.
Unfortunately the standard means of presenting views in SwiftUI is that they are truly modal – they capture the whole interaction context for the current scene, and you can’t interact with anything else until the modal is dismissed.
This is also the case for iPadOS. Even though a modal presented with .sheet on an iPad allows much more of the underlying view to be visible, you can’t interact with it until the sheet disappears. You can interact with different parts of the app by running two scenes side-by-side in split screen mode, but each half is a separate scene and any presented sheets are modal for that scene.
If you want one tab to optionally present a view over its usual content but still allow access to the tab view and its other tabs, that’s not a modal context and SwiftUI’s built-in sheet won’t work. You will have to implement something yourself - but I think that’s doable.
Rather than using .sheet, you could optionally add an overlay to your Tab1 view, using the same boolean state variable showingSheet. In this approach, the default dismiss environment variable won’t be available, so passing in the state variable as a binding value would be an alternative:
var body: some View
<main display>
.overlay(showingSheet ? Sheet1(presented: $showingSheet) : EmptyView())
You might also find that a ZStack works better than .overlay depending on what the contents of the tab view actually are.
You’ll definitely have a lot more structural work to do to make this work, but I hope you can see that it’s possible.
I have a button in a view (inside a NavigationView) that opens a full screen cover - a loading screen while some data is processing. When the cover is dismissed, I want to automatically route to the next view programmatically. I'm using a NavigationLink with a tag and selection binding, and the binding value updates when the cover is dismissed, but the routing doesn't happen unless I tap that same "open modal" button again.
import SwiftUI
struct OpenerView: View {
#EnvironmentObject var viewModel: OpenerViewModel
#State private var selection: Int? = nil
#State private var presentLoadingScreen = false
var body: some View {
VStack {
NavigationLink(destination: SecondScreen(), tag: 1, selection: $selection) { EmptyView() }
Button(action: {
viewModel.frequency = 0
self.presentLoadingScreen.toggle()
}, label: {
Text("Open cover")
}).buttonStyle(PlainButtonStyle())
}
.navigationBarTitle("Nav title", displayMode: .inline)
.fullScreenCover(isPresented: $presentLoadingScreen, onDismiss: {
self.selection = 1
}, content: ModalView.init)
}
}
struct ModalView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Text("Howdy")
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
presentationMode.wrappedValue.dismiss()
}
}
}
}
The first time I hit the Button, the cover opens. Inside the cover is just a DispatchQueue.main.asyncAfter which dismisses it after 2 seconds. When it's dismissed, the onDismiss fires, but then I have to hit the button again to route to SecondScreen.
Any ideas?
Edit: Added the modal's View
I got it working with some changes to the code, and I'm sharing here along with what I think is happening.
I believe the problem is a race condition using a #State boolean to toggle the cover and the navigation. When the cover is being dismissed, my main OpenerView is being recreated - to be expected with state changes. Because of this, I try to set the #State var selection to trigger the navigation change, but before it can do so, the view is recreated with selection = nil.
There seem to be two ways to solve it in my case:
Move the cover boolean to my view model - this worked, but I didn't want it there because it only applied to this view and it's a shared view model for this user flow. Plus, when the modal is dismissed, you see the current OpenerView for a brief flash and then get routed to the SecondScreen.
Keep the cover boolean in #State, but trigger the navigation change in the button immediately after setting the boolean to open the modal. This worked better for my use case because the modal opens, and when it closes, the user is already on the next screen.
I had a similar problem where I was trying to draw a view after dismissing a fullScreenCover. I kept getting an error that said that the view had been deallocated since it was trying to draw to the fullScreenCover.
I used Joe's hints above to make this work. Specifically:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
viewToShow()
}
I had previously tried onChange, onDisappear, onAppear - but none of those fit the use case I needed.
I have two List's in a view and want to be able to determine which list currently has the focus in order to show the correct details of the selected item in the list in a details panel.
The following code never seems to get called, can anyone indicate whether there is another correct way to determine when focus changes.
struct StoreList: View {
#EnvironmentObject private var database: Database
#Binding var selectedStore: Store?
var body: some View {
List(selection: $selectedStore) {
ForEach(database.stores, id: \.self) { store in
StoreRow(store: store).tag(store)
.focusable(true, onFocusChange: { isFocused in
print("focus changed")
if isFocused {
self.database.selectedType = .store
}
})
}
}
.focusable(true, onFocusChange: { isFocused in
print("focus changed")
if isFocused {
self.database.selectedType = .store
}
})
}
}
In the meantime I will explore detecting mouse clicks on the Rows since the user would need to click on an item in the list to move the focus.
Currently I am setting the selectedType value when an item changes (i.e. $selectedStore) in the view model (database) but if the user selected the already selected item in the other list then the value does not get updated but the List and list item does get the focus - well the visual colour change indicates it has the focus.
EDIT:
I have also tried processing the onTapGesture callback which works fine except it replaces the List rows default behaviour. How can I make sure the event is passed through to the List as this might work then.
The easiest method of reacting to focus is
struct DummyView: View {
#Environment(\.isFocused) var isFocused
var body: some View {
Text("My view")
.padding()
.background(isFocused ? Color.orange : Color.black)
}
}
I am creating an App where the login / register part is inside a modal, which is shown if the user is not logged in.
The problem is, that the user can dismiss the modal by swiping it down...
Is it possible to prevent this?
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) { // This needs to be non-dismissible
LoginRegister()
}
}
Second example:
I am using a modal to ask for information. The user should not be able to quit this process except by dismissing the modal with save button. The user has to input information before the button works. Unfortunately the modal can be dismissed by swiping it down.
Is it possible to prevent this?
iOS 15 and later:
Use .interactiveDismissDisabled(true) on the sheet, that's all.
Prev iOS 15:
You can try to do this by using a highPriorityGesture. Of course the blue Rectangle is only for demonstration but you would have to use a view which is covering the whole screen.
struct ModalViewNoClose : View {
#Environment(\.presentationMode) var presentationMode
let gesture = DragGesture()
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 300, height: 600)
.highPriorityGesture(gesture)
.overlay(
VStack{
Button("Close") {
self.presentationMode.value.dismiss()
}.accentColor(.white)
Text("Modal")
.highPriorityGesture(gesture)
TextField("as", text: .constant("sdf"))
.highPriorityGesture(gesture)
} .highPriorityGesture(gesture)
)
.border(Color.green)
}
}
This is a common problem and a "code smell"... well not really code but a "design pattern smell" anyway.
The problem is that you are making your login process part of the rest of the app.
Instead of presenting the LoginRegister over the App you should really be showing either App or LoginRegister.
i.e. you should have some state object like userLoggedIn: Bool or something and depending on that value you should show either App or LoginRegister.
Just don't have both in the view hierarchy at the same time. That way your user won't be able to dismiss the view.
If you dont mind using Introspect:
import Introspect
#available(iOS 13, *)
extension View {
/// A Boolean value indicating whether the view controller enforces a modal behavior.
///
/// The default value of this property is `false`. When you set it to `true`, UIKit ignores events
/// outside the view controller's bounds and prevents the interactive dismissal of the
/// view controller while it is onscreen.
public func isModalInPresentation(_ value: Bool) -> some View {
introspectViewController {
$0.isModalInPresentation = value
}
}
}
Usage:
.sheet {
VStack {
...
}.isModalInPresentation(true)
}
iOS 15+
Starting from iOS 15 you can use interactiveDismissDisabled.
You just need to attach it to the sheet:
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) {
LoginRegister()
.interactiveDismissDisabled(true)
}
}
Regarding your second example, you can pass a variable to control when the sheet is disabled:
.interactiveDismissDisabled(!isAllInformationProvided)
You can find more information in the documentation.
theoretically this may help you (I didn't tryed it)
private var isDisplayedBind: Binding<Bool>{ Binding(get: { true }, set: { _ = $0 }) }
and usage:
content
.sheet(isPresented: isDisplayedBind) { some sheet }