Bindings from the sheet modifier - swift

I am using the SwiftUI sheet modifier on a View that has a collection published from its view model. When a user clicks on a list item, I am setting an optional State value to the corresponding item from the list. The sheet modifier uses the $selectedSubscription value to present and hide the sheet when needed, and passes the value to the closure in order for me to use it in a details view. I have set up the details view to receive a #Binding to a list item in order to edit it at a later stage. The problem is that SwiftUI will not let me pass the item in the closure as a binding.
struct SubscriptionsListView: View {
#StateObject var vm = SubscriptionsListVM()
#State private var selectedSubscription: PVSubscription? = nil
var body: some View {
NavigationView {
ZStack {
...
}
.sheet(item: $selectedSubscription) { subscription in
SubscriptionDetailsView(subscription: $subscription) <--- error here
.presentationDetents([.medium, .large])
}
}
}
}
struct SubscriptionDetailsView: View {
#Binding var subscription: PVSubscription
...
}
I already tried to get the index of the item from the array and to pass that as a binding, but I was unsuccessful.

The problem arises from the use of the sheet modifier. You are trying to use the "selectedSubscription" variable both for showing a sheet and showing the details of your model. You have to create another variable for showing the sheet.
Since your sheet only shows one view, you don't need the sheet modifier with the item argument. Item argument is used for showing different views in the same sheet modifier (settings, profile etc.). It doesn't give you a binding in the trailing closure to put into the subview.
struct SubscriptionsListView: View {
#StateObject var vm = SubscriptionsListVM()
#State private var selectedSubscription: PVSubscription? = nil
#State private var showSheet: Bool = false
var body: some View {
NavigationView {
ZStack {
... // Toggle show sheet here
}
.sheet(isPresented: $showSheet) {
SubscriptionDetailsView(subscription: $selectedSubscription)
.presentationDetents([.medium, .large])
}
}
}
}

Related

How to pass data from a modal view list to parent view in SwiftUI?

I have (probably) an easy question related to SwiftUI state management.
A have a modal view with a simple list of buttons:
struct ExerciseList: View {
var body: some View {
List {
ForEach(1..<30) { _ in
Button("yoga") {
}
}
}
}
}
The parent view is this one:
struct SelectExerciseView: View {
#State private var showingSheet = false
#State private var exercise = "select exercise"
var body: some View {
Button(exercise) {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet){
ExerciseList()
}
}
}
How can I do to pass the selected button text from the list to the parent view ?
I'm thinking that I need a Binding variable inside the modal and use that, but not really sure how in this example.
At its most basic, you need the selected exercise in your parent view (SelectExerciseView) as a state variable. You then pass that in to the child view (the modal) via a binding. Assuming exercise as a string holds the variable you want to change:
.sheet(isPresented: $showingSheet) {
ExerciseList(exercise: $exercise)
}
Your modal then needs to have a #Binding reference.
struct ExerciseList: View {
#Binding var exercise: Exercise
var body: some View {
List {
ForEach(1..<30) { _ in
Button("yoga") {
exercise = "yoga"
}
}
}
}
}
Im not sure what you're asking...
Are you trying to show a "Detail View" from the modal.
Meaning theres the parent view -> Modal View -> Detail View
In your case it would be the SelectExerciseView -> ExerciseListView -> DetailView which shows the text of the button that was pressed on the previous view (can be any view you want)
If thats what you're trying to do I would use a NavigationLink instead of a button on the modal. The destination of the NavigationLink would be the detail view

Issue with setting #State variable dynamically

As in the code below, the choosenKeyboardKnowledge is a #State variable and was initiated as the first object read from the cache. Then in the body, I iterate each object and wrap it into a Button so that when clicked it leads to the corresponding sheet view. But each time after I run the preview and click on whichever button in the list view it always shows the first default view (set in the initializer), and if I dismiss it and click on another line it shows the correct view.
struct KeyboardKnowledgeView: View {
var keyboardKnowledges: [KeyboardKnowledge]
#State private var choosenKeyboardKnowledge: KeyboardKnowledge
#State private var showSheet: Bool = false
init() {
keyboardKnowledges = KeyboardKnowledgeCache.getKeyboardKnowledges()
_choosenKeyboardKnowledge = State(initialValue: keyboardKnowledges[0])
}
var body: some View {
ZStack {
Color.bgGreen.ignoresSafeArea()
List(keyboardKnowledges) { knowledge in
Button(action: {
self.choosenKeyboardKnowledge = knowledge
self.showSheet.toggle()
}) {
Text(knowledge.name)
}
.sheet(isPresented: $showSheet) {
KeyboardKnowledgeDetailsView(keyboardKnowledge: choosenKeyboardKnowledge)
}
}
}
}
}

#Published in an ObservableObject vs #State on a View leads to unpredictable update behavior in SwiftUI

This question is coming on the heels of this question that I asked (and had answered by #Asperi) yesterday, but it introduces a new unexpected element.
The basic setup is a 3 column macOS SwiftUI app. If you run the code below and scroll the list to an item further down the list (say item 80) and click, the List will re-render and occasionally "jump" to a place (like item 40), leaving the actual selected item out of frame. This issue was solved in the previous question by encapsulating SidebarRowView into its own view.
However, that solution works if the active binding (activeItem) is stored as a #State variable on the SidebarList view (see where I've marked //#1). If the active item is stored on an ObservableObject view model (see //#2), the scrolling behavior is affected.
I assume this is because the diffing algorithm somehow works differently with the #Published value and the #State value. I'd like to figure out a way to use the #Published value since the active item needs to be manipulated by the state of the app and used in the NavigationLink via isActive: (say if a push notification comes in that affects it).
Is there a way to use the #Published value and not have it re-render the whole List and thus not affect the scrolled position?
Reproducible code follows -- see the commented line for what to change to see the behavior with #Published vs #State
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
class SidebarListViewModel : ObservableObject {
#Published var items = Array(0...300).map { Item(name: "Item \($0)") }
#Published var activeItem : Item? //#2
}
struct SidebarList : View {
#StateObject private var viewModel = SidebarListViewModel()
#State private var activeItem : Item? //#1
var body: some View {
List(viewModel.items) {
SidebarRowView(item: $0, activeItem: $viewModel.activeItem) //change this to $activeItem and the scrolling works as expected
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: Text(item.name),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
(Built and tested with Xcode 13.0 on macOS 11.3)
Update. I still think that the original answer identified the problem, however seems that there's an even easier workaround to this: push the view model one level upstream, to the root ContentView, and inject the items array to the SidebarList view.
Thus, the following changes should fix the "jumping" issue:
struct SidebarList : View {
let items: [Item]
#Binding var activeItemId: UUID?
// ...
}
// ...
struct ContentView : View {
#StateObject private var viewModel = SidebarListViewModel()
var body: some View {
NavigationView {
SidebarList(items: viewModel.items,
activeItemId: $viewModel.activeItemId)
// ...
}
For some reason, this works, I don't have an explanation why. However, there's one problem left, that's caused by SwiftUI: programatically changing the selection won't make the list scroll to the new selection. Scroll SwiftUI List to new selection might help fixing this too.
Also, warmly recommending to move the NavigationLink from the body of SidebarRowView to the List part of SidebarList, this will help you limit the amount of details that get leaked to the row view.
Another recommendation I would make, would be to use the tag:selection: alternative to isActive. This works better when you have a pool of possible navigation links from which only one can be active at a certain time. This involves of course changing the view model from var activeItem: Item? to var activeItemId: UUID?, this will avoid the need of the hacky navigationBindingForItem function:
class SidebarListViewModel : ObservableObject {
#Published var items = // ...
#Published var activeItemId : UUID?
}
// ...
NavigationLink(destination: ...,
tag: item.id,
selection: $activeItemId) {
Original Answer
This is most likely what's causing the problematic behaviour:
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
If you put a breakpoint on the binding setter, you'll see that the setter gets called every time you select something, and if you also print the item name, you'll see that when the problematic scrolling happens, it always scroll to the previous selected item.
Seems this "manual" binding interferes with the SwiftUI update cycle, causing the framework to malfunction.
The solution here is simple: remove the #Binding declaration from the activeItem property, and keep it as a "regular" one. You also can safely remove the isActive argument passed to the navigation link.
Bindings are needed only when you need to update values in parent components, most of the time simple values are enough. This also makes your views simpler, and more in line with the Swift/SwiftUI principles of using immutable values as much as possible.

Why does my SwiftUI View not update on updating of an #State var?

I am having a strange issue with an #State var not updating an iOS SwiftUI view.
I have an edit screen for themes for a small game with a NavigationView with a list of game themes. When in edit mode and I select one of these themes, I open up an editor view, passing the theme as a binding to the editor view struct.
In my editor view I then have sections that allow the user to edit properties of the theme. I do not want to use bindings to the various theme properties in my edit fields because I do not want the changes to take effect immediately. Instead, I have created #State vars for each of these properties and then use bindings to these in the edit fields. That way, I give the user the option to either cancel without and changes taking effect, or select "Done" to assign the changes back to the theme via the binding.
In order to initialise the #State vars I have an onAppear block that assign the #State vars values from the respective theme properties.
The issue I am having is that when the onAppear block is executed and the vars are assigned, the relevant edit fields are not updating!
Here is a cut-down version of my code:
struct EditorView: View {
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#Binding var theme: GameTheme
#State private var name = ""
...
var body: some View {
NavigationView {
Form {
nameSection
...
}
.navigationTitle("Edit \(theme.name)")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: cancel)
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: saveTheme)
.disabled(!canSaveTheme)
}
}
.onAppear {
name = theme.name
...
}
}
.frame(minWidth: Constants.minViewSize.width, minHeight: Constants.minViewSize.height)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
...
}
So the view gets shown an on appearing, the #State var name does correctly get assigned the value from theme.name; however, this allocation does not cause an update of the view and the value of "name" is not entered into the TextField.
Interestingly, and I do not know if this is a good thing to do, if I wrap the contents of the onAppear block in a DispatchQueue.main.async, everything works fine!
i.e.
.onAppear {
DispatchQueue.main.async {
name = theme.name
...
}
}
Does anyone have any idea as to how, within the onAppear, I can force a view refresh? Or, why the assignment to "name" does not force an update?
Thanks.
This isn't the answer per se, but I went ahead and created a new iOS project with the following code (based on your post, but I cleaned it up a bit and came up with the missing GameTheme object myself).
It's more or less the same, and shows that your posted structure does re-render.
I'm wondering if there's more to the code we can't see in your post that could be causing this.
Are you possibly setting the name state variable anywhere else in a way that could be overriding the value on load?
import SwiftUI
#main
struct TestIOSApp: App {
#State var gameTheme: GameTheme = GameTheme(name: "A game theme")
var body: some Scene {
WindowGroup {
ContentView(theme: $gameTheme)
}
}
}
struct GameTheme {
var name:String;
}
struct ContentView: View {
#Binding var theme:GameTheme;
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#State private var name = "DEFAULT SHOULD NOT BE DISPLAYED"
var body: some View {
NavigationView {
Form {
nameSection
}
.navigationTitle("Edit \(theme.name)")
.onAppear {
name = theme.name
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: {})
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: {})
}
}
.frame(maxWidth:.infinity, maxHeight: .infinity)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
}
I seem to have solved my problem with an init(). I created init(theme: Binding<GameTheme>) and then within the init assigned the theme via _theme = theme and then assigned the name via _name = State(initialValue: theme.name.wrappedValue).

SwiftUI - Binding in child view is not updating

I have two views currently. A parent view with a List of `Pledges' (basically just a struct with a string and an image), and a child view which displays a confirmation of the pledge you selected from the list.
I have a state variable in the parent and in the child, a binding to this variable. However, the List item selected isn't always the one which is passed to the child confirmation view. It's as if the child binding variable isn't updating when a list item is selected.
does anyone have any ideas? thanks
Parent view:
let worstArea: String
#State var selection: String? = nil
var body: some View {
let pledgeList = getListOfPledges(worstArea: worstArea)
List(pledgeList) {
pledge in
Spacer()
NavigationLink(destination: PleadeConfirmation(pledgePicked: $selection)) {
pledgeRow(pledge: pledge, worstArea: worstArea).onTapGesture {
selection = pledge.description
}
}
}
}
Child view (PleadeConfirmation):
struct PleadeConfirmation: View {
#Binding var pledgePicked:String?
var body: some View {
Text("Commit to pledge: \(pledgePicked ?? "none selected")").multilineTextAlignment(.center)
}
}
The pledgePicked variable in child is the one in question which doesn't always reflect the list value being selected