I'm developing an app, one view of which has the primary goal of allowing users to reorder a List of NavigationLinks, but which I would also like to allow navigation & a few other things. I want users to be able to:
Reorder by dragging on the reorder control.
Navigate to the link by tapping elsewhere on the row.
Swipe from the leading edge to activate a swipe action.
At the moment, enabling EditMode (to allow reordering) disables navigation & swipe actions; I haven't been able to find a workaround that allows all 3 functions simultaneously. Is there a good way to do this?
Here's an example:
struct ReorderableListView: View {
var items: [X] // List of custom objects
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: CustomView(item)) { // winds up disabled by edit mode
Text(item.name)
}
.swipeActions(edge: .leading) { Button("Swipe") {print("swipe")} } // winds up disabled by edit mode
}
.onMove { from, to in
print("move \(from) to \(to)")
}
}
.environment(\.editMode, .constant(.active)) // always in edit mode for reordering
}
}
}
Update:
Based on the accepted answer, it looks like this behavior got updated in iOS 16: Now, if onMove is implemented, you can reorder with a long press even when not in edit mode, which allows these three actions to coexist. I've added custom drag-handles to make this behavior obvious to the user, but otherwise I'm going with exactly the solution given in the accepted answer, below.
I am not sure that the .environment is necessary in this case (I could be wrong). You can remove that piece.
Additionally, you should add an ID to each item in your foreach. This should ideally come from your model when you create new items (for example, your model can contain an ID variable = UUID()), but for the time being we can add it inline in your foreach.
I had to write some code on my end to get this up and running, so my solution is based on the code I spun up (very similar to yours, but missing your custom items object):
struct ReorderableListView: View {
var items: [X] // List of custom objects
var body: some View {
NavigationView {
List {
// added ID here
ForEach(items, id: \.self) { item in
NavigationLink(destination: CustomView(item)) { // winds up disabled by edit mode
Text(item.name)
}
.swipeActions(edge: .leading) { Button("Swipe") {print("swipe")} } // winds up disabled by edit mode
}
.onMove { from, to in
print("move \(from) to \(to)")
}
}
// REMOVED .environment(\.editMode, .constant(.active)) // always in edit mode for reordering
}
}
}
For reference, here is the code I wrote locally to fill in the gaps. This is what worked & what I implemented within your code:
struct ReorderableListView: View {
#State var items = [1, 2, 3] // List of custom objects
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) { item in
NavigationLink(destination: ContentView2()) { // winds up disabled by edit mode
Text("\(item)")
}
.swipeActions(edge: .leading) { Button("Swipe") {print("swipe")} } // winds up disabled by edit mode
}
.onMove { from, to in
print("move \(from) to \(to)")
}
}
// .environment(\.editMode, .constant(.active)) // always in edit mode for reordering
}
}
}
Related
I have a view with a list and inside the list there is a toggle that is binded with a boolean in the viewmodel, if I turn on the toggle the boolean is true and viceversa, the issue here is that if I turn on the toggle, and then enter background when I reopen the app, the toggle appears turned off even when the boolean is true, then I go back to the previous screen and when I return to the screen with the toggle it appears turned on, is there a way to avoid this issue? here is the List code below:
List {
Section(header: Text("Flags")) {
ForEach(viewModel.flags.indices, id: \.self) { index in
Toggle(isOn: $viewModel.flags[index].isActive) {
Text(viewModel.flags[index].name.rawValue.camelCaseToCapitalized())
}
}
}
You can try to use the onAppear modifier to update the state of the toggle when the view appears.
List {
Section(header: Text("Flags")) {
ForEach(viewModel.flags.indices, id: \.self) { index in
Toggle(isOn: $viewModel.flags[index].isActive) {
Text(viewModel.flags[index].name.rawValue.camelCaseToCapitalized())
}
.onAppear {
viewModel.flags[index].isActive = true
}
}
}
The old way works, even with the new NavigationStack.
#SceneStorage("selection") private var selection: Int?
var body: some View {
NavigationStack {
List(1..<10, selection: $selection) { selection in
NavigationLink("\(selection)") {
Text("\(selection)")
}
}
}
}
Replace what's in the NavigationStack with this, though, and the views don't get pushed.
List(1..<10, selection: $selection) { selection in
NavigationLink("\(selection)", value: selection)
}
.navigationDestination(for: Int.self) {
Text("\($0)")
}
Get rid of the selection argument, and turn that line into the following? Then the views do get pushed, but of course, the selection won't be bound to whatever else you need it for (scene storage, here).
List(1..<10) { selection in
It seems to be a bug in SwiftUI under iOS 16. It works fine on macOS Ventura.
I've resorted to this horrible workaround that only does selection (not deselection), but is still better that nothing:
.navigationDestination(for: SomeObject.self) { item in
let _ = Task {
self.selection = item // set selection here
}
SomeDetailView()
}
I've reported the bug to Apple, but since SwiftUI releases are tied to OS releases, there probably won't be a clean solution for another year or so.
Not really sure why would you need that exactly in List (highlighting works anyway), but probably you wanted this
List(1..<10) { selection in
NavigationLink("\(selection)", value: selection)
}
.navigationDestination(for: Int.self) { item in
Text("\(item)")
.onAppear { selection = item }
}
or even (again probably) you just needed path of NavigationStack(path: like in https://stackoverflow.com/a/72586530/12299030
I'm finishing up online auditing of Stanford CS193P class (great class BTW) and I have an oddity on my last assignment. I have created a theme data store and I use it to select a theme (color, number of pairs of cards, emoji) and then kick off and play a matching game. That works fine. Using an edit button, the user can edit a theme and change any of the theme elements.
I run into a problem the first time I use the edit button and select a theme to edit. My code acts as if the #State myEditTheme is nil. If I force unwrap it it crashes. I have put it in a nil-coalescing option as shown, the edit window comes up with the first theme in the array. Any subsequent edit attempts work normally.
In the tap gesture function, I set the value of the #State var myEditTheme, then I set the themeEditing to true. My debug print statement indicates that the myEditTheme has been properly set. When the sheet(isPresented: $themeEditing) presents the ThemeEditor in a "sheet" view, the initial value of myEditTheme is nil.
Is there a timing issue between when I set it in the tap function and when Swift senses that themeEditing is true? The code below is obviously not functional as is, I have edited it for conciseness, only showing relevant portions.
struct ThemeManager: View {
#EnvironmentObject var store: ThemeStore // injected
#State private var editMode: EditMode = .inactive
// inject a binding to List and Edit button
#State private var myEditTheme: Theme?
#State private var themeEditing = false
// used to control .sheet presentation for theme editing
var body: some View {
NavigationView {
List {
ForEach(store.themes) { theme in
NavigationLink(destination: ContentView(viewModel: EmojiMemoryGame(theme: theme))) {
VStack(alignment: .leading) {
Text(theme.name).font(.title2)
Text(theme.emojis).lineLimit(1)
} // end VStack
.sheet(isPresented: $themeEditing) {
ThemeEditor(theme: $store.themes[myEditTheme ?? theme])
.environmentObject(store)
}
.gesture(editMode == .active ? tap(theme) : nil)
} // end NavigationLink
} // end ForEach
} // end List
.navigationTitle("Themes")
.navigationBarTitleDisplayMode(.inline) // removes large title, leaves small inline one
.toolbar {
ToolbarItem { EditButton() }
ToolbarItem(placement: .navigationBarLeading) {
newThemeButton
}
}
.environment(\.editMode, $editMode)
} // NavigationView
} // body
private func tap(_ theme:Theme) -> some Gesture {
TapGesture().onEnded {
myEditTheme = theme
print("edit theme: \(myEditTheme)")
themeEditing = true
}
}
Goal: have a SwiftUI architecture where the "add new item" and "edit existing item" are solved by the same view (EditItemView). However, for some reason, when I do this, the runtime agent complains of "Modifying state during view update, this will cause undefined behavior".
This is the code I want to use, which ensures that the EDITING of the item and ADDING a new item are handled by the same EditItemView:
var body: some View {
NavigationView
{
ScrollView
{
LazyVGrid(columns: my_columns)
{
ForEach(items, id: \.id)
{
let item = $0
// THIS LINE TO EDIT AN EXISTING ITEM
NavigationLink(destination: EditItemView(item: item))
{
ItemView(item: item)
}
}
}
}
.navigationBarItems(trailing:
// THIS LINE TO ADD A NEW ITEM:
NavigationLink(destination: EditItemView(item: Data.singleton.createItem(name: "New item", value: 5.0))
{
Image(systemName: "plus")
}
)
}
}
It doesn't work, leading to the issue highlighted above. I am forced to separate the functionality for Edit and Add into two distinct Views, which then works:
var body: some View {
NavigationView
{
ScrollView
{
LazyVGrid(columns: my_columns)
{
ForEach(items, id: \.id)
{
let item = $0
// THIS LINE TO EDIT AN EXISTING ITEM
NavigationLink(destination: EditItemView(item: item))
{
ItemView(item: item)
}
}
}
}
.sheet(isPresented: $isPresented)
{
// FORCED TO USE SEPARATE VIEW
AddItemView { name, value in
_ = Data.singleton.createItem(name: name, value: value)
self.isPresented = false
}
}
.navigationBarItems(trailing: Button(action: { self.isPresented.toggle()}) { Image(systemName: "plus")})
}
}
I don't understand why the code in the first version is considered to modify the state while updating view, because to me, it's sequential: new Item is created and THEN a view is shown for that Item.
Any ideas?
The destination of NavigationLink is not rendered lazily, meaning it'll get rendered when the NavigationLink itself is rendered -- not when clicked through.
The sheet code, depending on platform and SwiftUI version, may have the same issue, but apparently does not in the version you're using. Or, the closure you provide to AddItemView isn't run immediately -- since you didn't include the code, it's not clear.
To solve the issue in the first method, you can use the following SO answer which provides a lazy NavigationLink: https://stackoverflow.com/a/61234030/560942
My need is simple but I can't seem to figure a way (and many posts are rather complicated or are old/for iOS).
I have a NavigationView with NavigationLink and it all works fine. For certain reasons, I'd like to know the item the user clicked on because it's used for other actions on the view.
I tried putting a button inside the NavigationView but then only the button action fires, but the link doesn't work.
I tried OnTapGesture - then the function fires but isn't propagated so the detail view never shows up.
What am I missing? Is there some way I can do this without overcomplicating?
ForEach(anArray, id: \.self) { entry in
NavigationLink(destination: SettingsView(s: entry)
{
Text("something")
}.onTapGesture {print("Hello")}
}
Here is the solution, actually use a button that does some action and activates a link
Tested with Xcode 11.5b2
#State private var activatedLink: Int? = nil
// ... other code
ForEach(Array(anArray.enumerated()), id: \.element) { (i, entry) in
Button("something") {
print(">> some action")
self.activatedLink = i // << order might be important !!
}.background(
NavigationLink(destination: SettingsView(s: entry), tag: i, selection: $activatedLink) { EmptyView() }
.buttonStyle(PlainButtonStyle()) // << required !!
)
}