How to avoid rebuild view when tap on different button on NavigationSplitView - swiftui

I have tried apple example
Bringing robust navigation structure to your SwiftUI app
so my code looks like this
NavigationSplitView(
columnVisibility: $navigationModel.columnVisibility
) {
List(
categories,
selection: $navigationModel.selectedCategory
) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
.toolbar {
ExperienceButton(isActive: $showExperiencePicker)
}
} detail: {
NavigationStack(path: $navigationModel.recipePath) {
RecipeGrid(category: navigationModel.selectedCategory)
}
}
Details View
struct RecipeGrid: View {
var category: Category?
var dataModel = DataModel.shared
var body: some View {
ZStack {
if let category = category {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
.buttonStyle(.plain)
}
}
.padding()
}
.navigationTitle(category.localizedName)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe) { relatedRecipe in
NavigationLink(value: relatedRecipe) {
RecipeTile(recipe: relatedRecipe)
}
.buttonStyle(.plain)
}
}
} else {
Text("Choose a category")
.navigationTitle("")
}
}
}
var columns: [GridItem] {
[ GridItem(.adaptive(minimum: 240)) ]
}
}
My issue is if I go to details view then tap on other sidebar item
after that return to same tap, it will return to rootview also onAppear it toggled!
that mean the view did rebuild itself
on Apple News app it will stay on save view
it won't rebuild or return to rootview when I change sidebar item
I want same behavior, but I don't know how can I do it
I didn't find any question here or any article
explain how to do the same behavior as Apple News app

I think the solution is rather complex. I tried modifying the code myself but there’s quite a few things that need to change.
Firstly, the problem is caused because here NavigationStack(path: $navigationModel.recipePath) the recipePath gets reset every time the users taps the sidebar. I wish I understood why, but I passed to path a manual binding Binding.init(get:set:).
Secondly, the route is [Recipe], so when you select another category it makes sense that this route will be emptied. Otherwise, when the user taps the sidebar, the selected item from the previous category would still show.
So you would need the route to be a dictionary ([Category: [Recipe]]) and you need to hold the route for each category.
After I made this change, I saw in Apple Documentation that you can restore the route after the view has appeared, probably this solves the first problem.
So every time the category would change, I would make a copy of the route for that category. Then I tried to restore it when the category was tapped again, but I got stuck here because onAppear wasn’t triggered for me on RecipeGrid(…).onAppear {}.

Related

Swiftui: How to Close one Tab in a TabView?

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.

Sheet can't be dismissed when removing a tab

In my app, I have two tabs. The second tab is shown or hidden based on some condition. I find if there is a sheet being presented in the second tab when the tab is to be hidden, the sheet can't be dismissed.
The issue can be consistently reproduced with the code below. To reproduce it, click tab 2, then click "Present Sheet", then click "Hide Tab 2". You will see the sheet isn't removed, though the tab containing it (that is, tab 2) has been removed (you can drag the sheet down to verify it).
It seems a SwiftUI bug to me. Does anyone know how to work around it? I'm close to finish my app but hit this unexpected issue :( Any help will be much appreciated.
struct ContentView: View {
#State var showTab2: Bool = true
var body: some View {
TabView {
// tab 1
NavigationView {
Text("Tab 1")
}
.tabItem {
Label("Tab 1", systemImage: "1.circle")
}
// tab 2
if showTab2 {
NavigationView {
Tab2(showTab2: $showTab2)
}
.tabItem {
Label("Tab 2", systemImage: "2.circle")
}
}
}
}
}
struct Tab2: View {
#State var showSheet: Bool = false
#Binding var showTab2: Bool
var body: some View {
VStack(spacing: 12) {
Text("Tab 2")
Button("Click to present sheet") {
showSheet = true
}
}
.sheet(isPresented: $showSheet, onDismiss: nil) {
NavigationView {
MySheet(showTab2: $showTab2)
}
}
}
}
struct MySheet: View {
#Environment(\.dismiss) var dismiss
#Binding var showTab2: Bool
var body: some View {
Button("Click to hide tab 2") {
// dismiss() works fine if I comment out this line.
showTab2 = false
dismiss()
}
}
}
I have submitted feedback on this to Apple, but I'm not optimistic for any reply (I have never received one).
Update:
The issue can be reproduced in many other scenarios where no sheet is involved. So, the second approach #Asperi gave is not a general solution.
Well, here we see conflict of actions (due to racing): async sheet closing (due to animation) and sync tab removing.
Here are possible approaches:
delay tab removing after sheet closed (implicit way)
Button("Click to hide tab 2") {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { // << here !!
showTab2 = false
}
}
remove tab after sheet closed (explicit way)
.sheet(isPresented: $showSheet, onDismiss: { showTab2 = false }) { // << here !!
NavigationView {
MySheet(showTab2: $showTab2)
}
}
Note: Actually when view knows/manages something for parent of parent is not very good design, so option 2 (maybe with some additional conditions/callbacks) are more preferable.
#Asperi gave a great answer. But it's not straightforward to apply his approaches in actual app. I'll explain why and how to do it below.
The key idea in Asperi's approaches is that, since the UI changes have race condition, they should be performed in two steps. In both approaches the sheet is dismissed first, then the tab is hidden.
In practice, however, it may not be obvious how to decouple the two steps. For example, my app works this way (I think it's typical):
The sheet contains a form and call data model API to mutate data model when the form is submitted by user.
Since the data model API may fail, the sheet doesn't dismiss itself as soon as user submits the form. Instead it does that only when the API call succeeds (the API call is synchronous).
When the data model is mutated, it may trigger the condition to hide the tab.
Note the item 2 and 3. It means the sheet have to call data model API first, which may hide the tab, and then dismiss itself.
It took me a while to think out the solution - introduce a dedicated state to control show/hide the tab and hence decouple the two steps. Now the issue left is how to synchronize data model change to that state. Since the purpose is to make them to appear as two separate changes to UI, we can't use Combine. It can be messy if not implemented property because data model can be mutated from everywhere (e.g. Form, ActionSheet, or just Button). Fortunately I find a very elegant approach:
.onChange(of: model.showTab2) { value in
// In my experiments async() works fine, but just to be on the safe side...
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// This is a state outside data model. It hides/shows tab2.
showTab2 = value
}
}
This is another example that there is no problem that can't be solved by adding another layer of abstraction :)

List ForEach not updating correctly

I am using Core data and Swiftui and everything have been working fine but in this one view I have a List, ForEach that is not working.
So for testing purpose my code currently look like this:
#ObservedObject var viewModel = NewLearningWhyViewModel()
VStack {
ForEach(viewModel.whys, id: \.self) { why in
Text(why.why)
}
List {
ForEach(viewModel.whys, id: \.self) { why in
Text(why.why)
}
}
Button(action: {
viewModel.createWhy(why: "Test", count: viewModel.whys.count, learning: learn)
viewModel.fetchWhy(predicate: NSPredicate(format: "parentLearning == %#", learn))
}){
Text("Add")
.font(.title)
.foregroundColor(.white)
}
}
The problem is my List { ForEach, first time I press add button it shows the new why, second time i press the button the whole list goes away, the third time I press the button the list shows again with all 3 items.
To test the problem I added that first ForEach part and that shows the correct item at all times, so there is not a problem with the viewmodel or adding the items, the items are added and it is published from the viewmodel since that part is updated.
Does anyone have any clue why my List { ForEach only show every other time?
I have gotten this problem. I figure it out by adding objectWillChange in ViewModel, and send() it manually when your why is changed. Actually I don't know your NewLearningWhyViewModel clearly, so this is just an example, you should try it out.
class NewLearningWhyViewModel: ObservableObject {
let objectWillChange: ObservableObjectPublisher = ObservableObjectPublisher()
#Published var whys: Why = Why() {
didSet {
objectWillChange.send()
}
}
}
Ok the post from Becky Hansmeyer solved it, adding .id(UUID()) to the list solved it and it started working correctly...
because of "viewModel.whys" is set of classes.
SwiftUI does not work with classes directly.
There is 2 solutions:
make it struct instead of class + add #Published modifier inside of view
leave it as is + do it observable object and in init of your view assign into observed object.
More details here:
https://stackoverflow.com/a/62919526/4423545

SwiftUI how to detect when a view has the focus?

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)
}
}

Is it possible to make a modal non-dismissible in SwiftUI?

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 }