I'm trying to create navigation for my app using Navigation Stack and routing.
My code is functioning and navigating to views, the problem I'm having is that the view is getting called several times from within a switch statement, I have placed the nav stack in the some scene view, then added a simple link, when tapped it goes through the switch statement and picks up the value 3 times and displays the view, I placed a print statement in the switch and it's printed 3 times for my new view value, following on with database calls etc, they are also getting called 3 times.
I'm new to SwiftUI so I'm sure it's user error, any help would be appreciated, thanks.
enum Routing : Hashable {
case AddFlight
case PilotsList
case newview
}
#State var navPath = NavigationPath()
var body: some Scene {
WindowGroup {
NavigationStack (path: $navPath) {
NavigationLink(value: Routing.newview, label: {Text("Go to new view")})
.navigationDestination(for: Routing.self) { route in
switch route {
case .newview:
Text("New View")
let a = print("New view")
case .PilotsList :
PilotsListView()
case .AddFlight:
AddEditFlightView()
}
}
}
}
}
Putting this in an answer because there is a code "fix" for the reprints.
Verified the behavior both in your code and some of my own existing case statements (XCode Version 14.0.1 (14A400)). Additionally the child view's init is called the same number of multiple times, but work in .onAppear() is only called the once. The number of extra calls seems to vary. Have also verified that it happens even when there isn't a case statement but a single possible ChildView.
This makes me think it may have to do with the Layout system negotiating a size for the view. This closure is a #ViewBuilder which we can tell because we can't just put a print statement into it directly. It's being treated as a subview that's negotiating it's size with the parent. Which makes sense in hindsight, but wow, good to know!
This means that items that should only happen once should go in the .onAppear() code of the child view instead of inside of the destination closure which is a #ViewBuilder.
This code is fairly different than yours but that was mostly to check that the effect wasn't some quirk. The key is that it will only do the "onAppear" task once. Note that the perform closure for .onAppear is NOT a ViewBuilder, it is of type () -> Void. It is possible you might prefer .task{} to do an async database call, and that will also just run the once, it looks like.
struct ThreeTimePrintView:View {
#EnvironmentObject var nav:NavigationManager
var body: some View {
NavigationStack (path: $nav.navPath) {
Button("New View") {
nav.navPath.append(Routing.newview)
}.navigationDestination(for: Routing.self) { route in
switch route {
case .newview:
buildNewView().onAppear() { print("New View onAppear") }
case .PilotsList :
Text("Pilot View")
case .AddFlight:
Text("FligtView")
}
}
}
}
func buildNewView() -> some View {
print("New view")
return Text("New View")
}
}
import SwiftUI
enum Routing : Hashable {
case AddFlight
case PilotsList
case newview
}
final class NavigationManager:ObservableObject {
#Published var navPath = NavigationPath()
}
#main
struct ThreePrintCheckerApp: App {
#StateObject var nav = NavigationManager()
var body: some Scene {
WindowGroup {
ThreeTimePrintView().environmentObject(nav)
}
}
}
Related
I post the minimum code to reproduce the behavior. Tested on latest macOS and Xcode.
The picker in this example is just a wrapper for the default picker and conforms to Equatable (this is a must to prevent the view from updating when properties doesn't change in the real world view) and categories:
enum Category: Int, Identifiable, CaseIterable {
case one, two, three
var id: Self { self }
var name: String {
switch self {
case .one:
return "First"
case .two:
return "Second"
case .three:
return "Third"
}
}
}
struct CustomPicker: View, Equatable {
static func == (lhs: CustomPicker, rhs: CustomPicker) -> Bool {
lhs.selectedCategory == rhs.selectedCategory
}
#Binding var selectedCategory: Category
var body: some View {
VStack {
Picker("Picker", selection: $selectedCategory) {
ForEach(Category.allCases) { category in
Text(category.name)
}
}
}
}
}
And a simple model to bind to:
final class Model: ObservableObject {
#Published var selectedCategory: Category = .two
}
Now in ContentView:
struct ContentView: View {
#StateObject private var model = Model()
#State private var selectedCategory: Category = .two
var body: some View {
VStack {
Text("Picker bug")
HStack {
CustomPicker(selectedCategory: $selectedCategory)
CustomPicker(selectedCategory: $model.selectedCategory)
}
}
.padding()
}
}
Here is the problem. If I bind the CustomPicker to the #Stateproperty it works as expected. However, if bound to the model's #Published property the value doesn't change when interacting with the control. When not using the Equatable conformance it works as expected.
What's more interesting is that if I change the pickerStyle to something else like segmented or inline it does work again.
Any idea why this happens? Probably a bug?
EDIT:
I found a hack/workaround... the thing is if the CustomPicker is inside a regular TabView it works fine.
TabView {
CustomPicker(selectedCategory: $model.selectedCategory)
.tabItem {
Text("Hack")
}
}
Strange behavior...
Since this only happens with one picker type (and then, only on macOS, iOS is fine), it does look like a bug with this specific picker style. If you add extra logging you can see that for other picker styles, it performs more equality checks, perhaps because there has been user interaction and the other options are visible, so different mechanisms are marking the view as dirty.
When the equality check is happening for this type of picker, it has the new value from the binding for both the left and right hand sides. If you move from a binding to passing the whole model as an observed object, then it works (because it ignores equatable at this level, it seems), but since you're interested in minimising redraws, that's probably not a great solution.
Note that according to the documentation you need to wrap views in EquatableView or use the .equatable() modifier to take advantage of using your own diffing. You might be better off working out where the performance problems you're trying to avoid are coming from, and fixing those instead.
Currently i am using NavigationView->NavigationLink to navigate one screen to another. How can i remove specific screen from NavigationView?
For Example I have four screen A, B, C and D. The navigation chain like this A->B-C->D. From Screen D how can i go back in Screen B and then Sceen A
To achieve this, you need to the new NavigationStack for iOS 16 which allows programmatic navigation.
The NavigationStack takes a path parameter; and array that you add or remove objects to, representing your navigation stack. Use the navigationDestination modifier to decide which actual views to push on the stack based on the contents of the path.
Example:
enum NavView {
case b, c, d
}
struct ContentView: View {
#State private var path: [NavView] = []
var body: some View {
NavigationStack(path: $path) {
VStack {
NavigationLink("Show B", value: NavView.b)
.navigationTitle("View A")
}
.navigationDestination(for: NavView.self) { view in
switch view {
case .b: ViewB(path: $path)
case .c: ViewC(path: $path)
case .d: ViewD(path: $path)
}
}
}
}
}
struct ViewB: View {
#Binding var path: [NavView]
var body: some View {
NavigationLink("Show C", value: NavView.c)
.navigationTitle("View B")
}
}
struct ViewC: View {
#Binding var path: [NavView]
var body: some View {
Button("Show D", action: { path.append(NavView.d) })
.navigationTitle("View C")
}
}
struct ViewD: View {
#Binding var path: [NavView]
var body: some View {
Button("Back to B", action: { path.removeLast(2) })
.navigationTitle("View D")
}
}
Note that the navigationDestination modifier is not on the NavigationStack, but one one of its contained views.
For this you can use either NavigationView and NavigationLink or the new NavigationStack. (I am not familiar yet with the latter but I can explain the general concept).
If you use the standard NavigationLink it will use a initialiser with a destination and a label. With this, you are limiting yourself as you are putting the view in control of the navigation. The view will push and pop and you are not in the loop so you can't achieve what you want.
However, you can use a different initialiser to create the NavigationLink that uses a binding to a Bool that determines if the navigation link is active (i.e. if it is pushed).
https://developer.apple.com/documentation/swiftui/navigationlink/init(isactive:destination:label:)
(This is deprecated in iOS16 though so it depends on what iOS version you are targeting).
With this you can now use the value of the isActive boolean to control the push of the navigation link. So if you have a boolean in your code you can update it and it will make the app pop back to the view you want to.
I have an app with many nested views, some which show a sheet based on a user action.
But I also have a sheet that I'd like to present on the main view based on a timer (ie, not a user action). But you can't have 2 sheets at the same time, so I'd like to check "something" to see if a sheet is already up, and not present the one from the timer.
I'd like to do this in a general way, and not check every place in the code where a sheet might be presented.
Any suggestions?
Ideally there'd be something in the core framework that could be queried to answer the question "Is there a sheet being shown?", but as a commenter pointed out, that is fraught with peril.
So I just decided to leave it alone, that the "default" behavior is fine (ie, it'll defer presenting the sheet until any other sheet is dismissed). In my case this is preferred to any other gyrations.
EDIT:
Eek! I just found out that if the sheet from the timer is popped up while an Alert is showing...it ruins the app. Once you dismiss the alert, any attempt to bring up any sheet anywhere fails. It's as if things got out of sync somewhere. I believe this is similar to:
Lingering popover causes a problem with alerts
If you have alerts in your app, you don't really want to do this.
Here is how you can handle the sheets - the example below is fully functioning, just pass the view model to the environment before calling TabsView() in the App.
Create an Identifiable object that will handle all the sheets in the program:
// This struct can manage all sheets
struct CustomSheet: Identifiable {
let id = UUID()
let screen: TypeOfSheet
// All sheets should fit here
#ViewBuilder
var content: some View {
switch screen {
case .type1:
SheetType1()
case .type2(let text):
SheetType2(text: text)
default:
EmptyView()
}
}
// All types of sheets should fit here
enum TypeOfSheet {
case type1
case type2(text: String)
case none
}
}
Create one optional #Published var and one function in the view model; the var will tell the program what sheet is open:
// Code to be included in the view model, so it can
// handle AND track all the sheets
class MyViewModel: ObservableObject {
// This is THE variable that will tell the code whether a sheet is open
// (and also which one, if necessary)
#Published var sheetView: CustomSheet?
func showSheet(_ sheet: CustomSheet.TypeOfSheet) {
// Dismiss any sheet that is already open
sheetView = nil
switch sheet {
case .none:
break
default:
sheetView = CustomSheet(screen: sheet)
}
}
}
Usage:
open the sheets by calling the function viewModel.showSheet(...)
use .sheet(item:) to observe the type of sheet to open
use viewModel.sheet.screen to know what sheet is open
sheets can also be dismissed using viewModel.showSheet(.none)
// Example: how to use the view model to present and track sheets
struct TabsView: View {
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
TabView {
VStack {
Text("First tab. Sheet is \(String(describing: viewModel.sheetView?.screen ?? .none))")
.padding()
Button("Open sheet type 1") {
// Show a sheet of the first type
viewModel.showSheet(.type1)
}
}
.tabItem {Label("Tab 1", systemImage: "house")}
VStack {
Text("Second tab. Sheet is \(viewModel.sheetView == nil ? "Hidden" : "Shown")")
.padding()
Button("Open sheet type 2") {
// Show a sheet of the second type
viewModel.showSheet(.type2(text: "parameter"))
}
}
.tabItem {Label("Tab 2", systemImage: "plus")}
}
// Open a sheet - the one selected in the view model
.sheet(item: $viewModel.sheetView) { sheet in
sheet.content
.environmentObject(viewModel)
}
}
}
The following code completes the minimal reproducible example:
// Just some sample views for the sheets
struct SheetType1: View {
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
Text("Takes no parameters. Sheet is \(viewModel.sheetView == nil ? "Hidden" : "Shown")")
}
}
struct SheetType2: View {
#EnvironmentObject var viewModel: MyViewModel
let text: String
var body: some View {
Text("Takes a string: \(text). Sheet is \(String(describing: viewModel.sheetView?.screen ?? .none))")
}
}
#main
struct MyApp: App {
let viewModel = MyViewModel()
var body: some Scene {
WindowGroup {
TabsView()
.environmentObject(viewModel)
}
}
}
I am trying to use the new App protocol for a new SwiftUI App and I need to detect a scenePhase change into .background at App level in order to persist little App data into a .plist file. I don't know if it is a bug or I am doing something wrong but it doesn't work as expected. As soon as a button is tapped, scenePhase change to .background when the scene is still active! In order to show an example of this weird behaviour, I am showing this simple code:
class DataModel: ObservableObject {
#Published var count = 0
}
#main
struct TestAppProtocolApp: App {
#Environment(\.scenePhase) private var scenePhase
#StateObject private var model: DataModel = DataModel()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(model)
}
.onChange(of: scenePhase) { newScenePhase in
switch newScenePhase {
case .active:
print("Scene is active.")
case .inactive:
print("Scene is inactive.")
case .background:
print("Scene is in the background.")
#unknown default:
print("Scene is in an unknown state.")
}
}
}
}
struct ContentView: View {
#EnvironmentObject var model: DataModel
var body: some View {
VStack {
Button(action: { model.count += 1 }) {
Text("Increment")
}
.padding()
Text("\(model.count)")
}
}
}
When the increment button is tapped, scenePhase changes to .background and then, when the App is really sent to background, scenePhase is not changed.
I found out that moving the .onChange(of: scenePhase) to the View (ContentView) works fine as I expect but Apple announced you can monitor any scenePhase change at App level and this is what I really want not at View level.
I also had a similar issue with scenePhase not working at all, then it worked but not as expected. Try removing only "#StateObject private" from your property and you will probably have other results. I hope new betas will fix this.
By the way, the recommended way of persisting little App-wide data in SwiftUI 2+ is through #AppStorage property wrapper which itself rests on UserDefaults. Here is how we can detect the first launch and toggle the flag:
struct MainView: View {
#AppStorage("isFirstLaunch") var isFirstLaunch: Bool = true
var body: some View {
Text("Hello, world!")
.sheet(isPresented: $isFirstLaunch, onDismiss: { isFirstLaunch = false }) {
Text("This is the first time app is launched. The sheet will not be shown again once dismissed.")
}
}
}
Xcode12 beta 6 seems to solve the issue. Now it works as expected.
I've been seeing some strange behavior for preference keys with ScrollView. If I put the onPreferenceChange inside the ScrollView it won't be called, but if I put it outside it does!
I've setup a width preference key as follows:
struct WidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
The following simple view does not print:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
But this works:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
I know that I can use the latter approach to fix this, but sometimes I'm inside a child view that does not have access to its parent scroll view but I still want to record a preference key.
Any ideas on how to get onPreferenceChange to get called inside a ScrollView?
Note: I get Bound preference WidthPreferenceKey tried to update multiple times per frame. when I put the function inside the scroll view, which might explain what is going on but I can't figure it out.
Thanks!
I had been trying to figure out this issue for a long time and have found how to deal with it, although the way I used was just one of the workarounds.
Use onAppear to ScrollView with a flag to make its children show up.
...
#State var isShowingContent = false
...
ScrollView {
if isShowingContent {
ContentView()
}
}
.onAppear {
self.isShowingContent = true
}
Or,
Use List instead of it.
It has the scroll feature, and you can customize it with its own functionality and UITableView appearance in terms of UI. the most important is that it works as we expected.
[If you have time to read more]
Let me say my thought about that issue.
I have confirmed that onPreferenceChange isn't called at the bootstrap time of a view put inside a ScrollView. I'm not sure if it is the right behavior or not. But, I assume that it's wrong because ScrollView has to be capable of containing any views even if some of those use PreferenceKey to pass any data among views inside it. If it's the right behavior, it would be quite easy for us to get in trouble when creating our custom views.
Let's get into more detail.
I suppose that ScrollView would work slightly different from the other container views such as List, (H/V)Stack when it comes to set up its child view at the bootstrap time. In other words, ScrollView would try to draw(or lay out) children in its own way. Unfortunately, that way would affect the children's layout mechanism working incorrectly as what we've been seeing. We could guess what happened with the following message on debug view.
TestHPreferenceKey tried to update multiple times per frame.
It might be a piece of evidence to tell us that the update of children has occurred while ScrollView is doing something for its setup. At that moment, it could be guessed that the update to PreferenceKey has been ignored.
That's why I tried to put the placing child views off to onAppear.
I hope that will be useful for someone who's struggling with various issues on SwiftUI.
I think onPreferenceChange in your example is not called because it’s function is profoundly different from preference(key…)
preference(key:..) sets a preference value for the view it is used on.
whereas onPreferenceChange is a function called on a parent view – a view on a higher position in the view tree hierarchy. Its function is to go through all its children and sub-children and collect their preference(key:) values. When it found one it will use the reduce function from the PreferenceKey on this new value and all the already collected values. Once it has all the values collected and reduced them it will execute the onPreference closure on the result.
In your first example this closure is never called because the Text(“Hello”) view has no children which set the preference key value (in fact the view has no children at all). In your second example the Scroll view has a child which sets its preference value (the Text view).
All this does not explain the multiple times per frame error – which is most likely unrelated.
Recent update (24.4.2020):
In a similar case I could induce the call of onPreferenceChange by changing the Equatable condition for the PreferenceData. PreferenceData needs to be Equatable (probably to detect a change in them). However, the Anchor type by itself is not equatable any longer. To extract the values enclosed in an Anchor type a GeometryProxy is required. You get a GeometryProxy via a GeometryReader. For not disturbing the design of views by enclosing some of them into a GeometryReader I generated one in the equatable function of the PreferenceData struct:
struct ParagraphSizeData: Equatable {
let paragraphRect: Anchor<CGRect>?
static func == (value1: ParagraphSizeData, value2: ParagraphSizeData) -> Bool {
var theResult : Bool = false
let _ = GeometryReader { geometry in
generateView(geometry:geometry, equality:&theResult)
}
func generateView(geometry: GeometryProxy, equality: inout Bool) -> Rectangle {
let paragraphSize1, paragraphSize2: NSSize
if let anAnchor = value1.paragraphRect { paragraphSize1 = geometry[anAnchor].size }
else {paragraphSize1 = NSZeroSize }
if let anAnchor = value2.paragraphRect { paragraphSize2 = geometry[anAnchor].size }
else {paragraphSize2 = NSZeroSize }
equality = (paragraphSize1 == paragraphSize2)
return Rectangle()
}
return theResult
}
}
With kind regards
It seems like the issue is not necessarily with ScrollView, but with your usage of PreferenceKey. For instance, here is a sample struct in which a PreferenceKey is set according to the width of a Rectangle, and then printed using .onPreferenceChange(), all inside of a ScrollView. As you drag the Slider to change the width, the key is updated and the print closure is executed.
struct ContentView: View {
#State private var width: CGFloat = 100
var body: some View {
VStack {
Slider(value: $width, in: 100...200)
ScrollView(.vertical) {
Rectangle()
.background(WidthPreferenceKeyReader())
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
.frame(width: self.width)
}
}
}
struct WidthPreferenceKeyReader: View {
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
As you noted, the first time the key tries to set, the console prints "Bound preference WidthPreferenceKey tried to update multiple times per frame," but a real value is immediately set afterward, and it continues to update dynamically.
What value are you actually trying to set, and what are you trying to do in .onPreferenceChange()?
I think this is because you implemented reduce() incorrectly.
You can find the details here:
https://stackoverflow.com/a/73300115/4366470
TL;DR: Replace value = nextValue() in reduce() with value += nextValue().
You may only read it in superView, but you can change it with transformPreference after you set it .
struct ContentView: View {
var body: some View {
ScrollView {
VStack{
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}.transformPreference(WidthPreferenceKey.self, {
$0 = 30})
}.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
The last value is 30 now. Hope it is what you want.
You can read from other layer:
ScrollView {
Text("Hello").preference(key: WidthPreferenceKey.self, value: CGFloat(40.0))
.backgroundPreferenceValue(WidthPreferenceKey.self) { x -> Color in
print(x)
return Color.clear
}
}
The problem here is actually not in ScrollView but in usage - this mechanism allow to transfer data up in viewTree:
A view with multiple children automatically combines its values for a
given preference into a single value visible to its ancestors.
source
The keywords here - with multiple children. This mean that u can pass it in viewTree from child to parent.
Let's review u'r code:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
As u can see now - child pass value to itself, and not to parent - so this don't want to work, as per design.
And working case:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
Here, ScrollView is parent and Text is child, and child talk to parent - everything works as expected.
So, as I sad in the beginning the problem here not in ScrollView but in usage and in Apple documentation (u need to read it few times as always).
And regarding this:
Bound preference WidthPreferenceKey tried to update multiple times per
frame.
This is because u may change multiply values in same time and View can't be rendered, try to .receive(on:) or DispatchQueue.main.async as workaround (I guess this may be a bug)