The user should be able to add new values to a picker if the desired option is not already there. So I basically want to implement an "Add" button to the view with the options.
The only way I get this to work is by adding the .navigationBarItems modifier to each option. But this seems not only an overhead, but it's also a problem if the list doesn't contain any options at the beginning (then the modifier isn't applied to any element and therefore the button isn't visible).
I already tried a couple of other things:
Adding the modifier to the ForEach is merging all options into one button, so individual options aren't selectable anymore.
Adding an EmptyView bellow Picker and attaching the modifier to it results in an extra empty line in the list.
import SwiftUI
struct ContentView: View {
#State private var selection = 1
let options = [Option(title: "Option 1", id: 1), Option(title: "Option 2", id: 2)]
struct Option {
var title: String
var id: Int
}
var body: some View {
NavigationView {
Form {
Picker("Test", selection: $selection) {
ForEach(options, id:\.id) { option in
Text(option.title).tag(option.id)
.navigationBarItems(trailing: Text("Add"))
}
.navigationBarTitle("Options", displayMode: .inline)
}
.navigationBarTitle("Form")
.navigationBarItems(trailing: EmptyView())
}
}
}
}
Changing diaplayMode on the fly always gives undesired effect, but if you'd have same one (or none for Options) then the following approach could work.
Tested with Xcode 11.4 / iOS 13.4
struct ContentView: View {
#State private var selection = 1
#State private var options = [Option(title: "Option 1", id: 1), Option(title: "Option 2", id: 2)]
struct Option {
var title: String
var id: Int
}
var body: some View {
NavigationView {
Form {
Picker("Test", selection: $selection) {
ForEach(options, id:\.id) { option in
self.optionRow(for: option)
}
.listRowInsets(EdgeInsets(top: 1, leading: 1, bottom: 1, trailing: 1))
}
}
.navigationBarTitle("Form")
// .navigationBarTitle("Form", displayMode: .inline)
}
}
#ViewBuilder
func optionRow(for item: Option) -> some View {
if item.id == selection {
Text(item.title)
} else {
Text(item.title)
.navigationBarTitle("Options")
// .navigationBarTitle("Options", displayMode: .inline)
.navigationBarItems(trailing: Button("Add") {
// example of creating new option
self.options.append(Option(title: "Option 3", id: 3))
})
}
}
}
Related
If an action in the menu rendered in LazyVStack causes the item wrapping it to disappear, when the item appears again via some other data change, the menu won’t display. This only happens if 1) the views are displayed in an LazyVStack and 2) the visibility change happens with some animation.
Here’s a small toy example:
import SwiftUI
import CoreMotion
struct Item: Equatable {
var id: String
var archived = false
}
struct ItemView: View {
let item: Item
let onChange: () -> Void
var body: some View {
HStack {
Text("item \(item.id)")
Menu {
Button {
onChange()
} label: {
Text("Switch")
}
} label: {
Text("menu")
}
}
}
}
class GroupOfItem: ObservableObject {
#Published var items: [Item] = [
Item(id: "a"),
Item(id: "b")
]
}
struct ContentView: View {
#State private var toggle = false
#StateObject private var groupOfItem = GroupOfItem()
var body: some View {
Toggle(isOn: $toggle) {
Text("toggle")
}
ScrollView {
LazyVStack {
ForEach(groupOfItem.items.filter { $0.archived == toggle }, id: \.id) { item in
ItemView(item: item) {
withAnimation {
groupOfItem.items[groupOfItem.items.firstIndex(of: item)!] = Item(id: item.id, archived: !item.archived)
}
}
}
}
}
}
}
Tapping on “switch” in the menu will cause the item to disappear with some animation, and switching the toggle will make it appear. However, notice that the menu is gone.
Is there a way to workaround this? VStack is not performant enough, List has its own quirks and without the animation the experience feels very jarring.
Could you work around this with a contextMenu? While not identical, it seems to work in OK in a LazyVStack
struct ItemView: View {
let item: Item
let onChange: () -> Void
var body: some View {
HStack {
Text("item \(item.id)")
Text("menu").foregroundColor(.accentColor)
.padding()
.contextMenu {
Button("Switch") {
onChange()
}
}
}
}
}
I want to do two things. Firstly, I want to navigate to SpaceView if I tap on 'TileCell'.
NavigationLink(destination: SpaceView(space: space)) {
TileCell(
image: image,
text: space.name!,
detailText: nil,
isFaded: space.isComplete
)
}
.buttonStyle(PlainButtonStyle())
}
This works great.
But I also want to long press on TileCell to trigger a different action.
NavigationLink(destination: SpaceView(space: space)) {
TileCell(
image: image,
text: space.name!,
detailText: nil,
isFaded: space.isComplete
)
.onLongPressGesture {
action()
}
}
.buttonStyle(PlainButtonStyle())
}
The long-press gesture works, but I can no longer navigate to SpaceView by tapping.
Any help getting both to work would be appreciated.
I would suggest manually triggering the NavigationLink with isActive Binding in the onTapGesture. Then you can handle on tap and long press.
struct ContentView: View {
#State var outputText: String = ""
#State var isActive : Bool = false
var body: some View {
NavigationView {
NavigationLink(destination: SpaceView(), isActive: $isActive) { //<< here use isActive
TileCell()
.onTapGesture {
isActive = true //<< activate navigation link manually
}
.onLongPressGesture {
print("Long press") //<< long press action here
}
}
}
}
}
Building off davidev's answer, here's a version that works with multiple NavigationLinks created with ForEach
struct ContentView: View {
#State var tileTextArray = ["Tile 1", "Tile 2", "Tile 3"]
#State var isActiveArray = [false, false, false]
var body: some View {
NavigationView {
ForEach(tileTextArray.indices, id: \.self) { index in
NavigationLink(destination: SpaceView(), isActive: $isActiveArray[index]) { //<< here use isActive
TileCell()
.onTapGesture {
isActiveArray[index] = true //<< activate navigation link manually
}
.onLongPressGesture {
print("Long press") //<< long press action here
}
}
}
}
}
}
I just try this code but I got unexpected edge for List.
If I remove navigationBarItems everything is ok.
import SwiftUI
let numbers: [String] = ["One", "Two", "Three"]
struct NavBarView: View {
var body: some View {
NavigationView {
List(numbers, id: \.self) { number in
SimpleRow(title: number)
}
.navigationBarTitle(Text("Numbers"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: { // add this will affect the List position and size
}, label: {Image(systemName: "plus").imageScale(.medium)}
))
}
}
}
struct SimpleRow: View {
var title: String
var body: some View {
HStack {
Text(title)
Spacer()
}
}
}
Interesting behavior, as it is only when modifier applied directly to List... even can't say if it is a bug... Maybe it is because navigationBarItems has deprecated.
Anyway here is possible solution - attach navigation bar items to something else, like background.
Tested with Xcode 12 / iOS 14
struct NavBarView: View {
var body: some View {
NavigationView {
List(numbers, id: \.self) { number in
SimpleRow(title: number)
}
.background(Color.clear
.navigationBarItems(trailing: Button(action: {
}, label: {Image(systemName: "plus").imageScale(.medium)}
)))
.navigationBarTitle(Text("Numbers"), displayMode: .inline)
}
}
}
Consider the following project with two views. The first view presents the second one:
import SwiftUI
struct ContentView: View {
private let data = 0...1000
#State private var selection: Set<Int> = []
#State private var shouldShowSheet = false
var body: some View {
self.showSheet()
//self.showPush()
}
private func showSheet() -> some View {
Button(action: {
self.shouldShowSheet = true
}, label: {
Text("Selected: \(selection.count) items")
}).sheet(isPresented: self.$shouldShowSheet) {
EditFormView(selection: self.$selection)
}
}
private func showPush() -> some View {
NavigationView {
Button(action: {
self.shouldShowSheet = true
}, label: {
NavigationLink(destination: EditFormView(selection: self.$selection),
isActive: self.$shouldShowSheet,
label: {
Text("Selected: \(selection.count) items")
})
})
}
}
}
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
#State private var editMode: EditMode = .active
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
Text("\(value)")
}
}.environment(\.editMode, self.$editMode)
}
}
Steps to reproduce:
Create an app with the above two views
Run the app and present the sheet with the editable list
Select some items at random indexes, for example a handful at index 0-10 and another handful at index 90-100
Close the sheet by swiping down/tapping back button
Open the sheet again
Scroll to indexes 90-100 to view the selection in the reused cells
Expected:
The selected indexes as you had will be in “selected state”
Actual:
The selection you had before is not marked as selected in the UI, even though the binding passed to List contains those indexes.
This occurs both on the “sheet” presentation and the “navigation link” presentation.
If you select an item in the list, the “redraw” causes the original items that were originally not shown as selected to now be shown as selected.
Is there a way around this?
It looks like EditMode bug, worth submitting feedback to Apple. The possible solution is to use custom selection feature.
Here is a demo of approach (modified only part). Tested & worked with Xcode 11.4 / iOS 13.4
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
self.cell(for: value)
}
}
}
// also below can be separated into standalone view
private func cell(for value: Int) -> some View {
let selected = self.selection.contains(value)
return HStack {
Image(systemName: selected ? "checkmark.circle" : "circle")
.foregroundColor(selected ? Color.blue : nil)
.font(.system(size: 24))
.onTapGesture {
if selected {
self.selection.remove(value)
} else {
self.selection.insert(value)
}
}.padding(.trailing, 8)
Text("\(value)")
}
}
}
I'd like to use the EditButton() to toggle edit mode, and have my list rows switch to edit mode. I want to include a new button in edit mode for opening a modal. I can't even get the EditMode value to switch the row content at all.
struct ContentView: View {
#Environment(\.editMode) var isEditMode
var sampleData = ["Hello", "This is a row", "So is this"]
var body: some View {
NavigationView {
List(sampleData, id: \.self) { rowValue in
if (self.isEditMode?.value == .active) {
Text("now is edit mode") // this is never displayed
} else {
Text(rowValue)
}
}
.navigationBarTitle(Text("Edit A Table?"), displayMode: .inline)
.navigationBarItems(trailing:
EditButton()
)
}
}
}
You need to set the environment value for editMode in the List:
struct ContentView: View {
#State var isEditMode: EditMode = .inactive
var sampleData = ["Hello", "This is a row", "So is this"]
var body: some View {
NavigationView {
List(sampleData, id: \.self) { rowValue in
if (self.isEditMode == .active) {
Text("now is edit mode")
} else {
Text(rowValue)
}
}
.navigationBarTitle(Text("Edit A Table?"), displayMode: .inline)
.navigationBarItems(trailing: EditButton())
.environment(\.editMode, self.$isEditMode)
}
}
}
You need to be careful, and make sure .environment(\.editMode, self.$isEditMode) comes after .navigationBarItems(trailing: EditButton()).
Adding to #kontiki answer, if you prefer using a boolean value for editMode so it is easier to modify, use this #State variable:
#State var editMode: Bool = false
And modify the .environment modifier to this:
.environment(\.editMode, .constant(self.editMode ? EditMode.active : EditMode.inactive))
Now switching to/from edit mode with your own button is as easy as:
Button(action: {
self.editMode = !self.editMode
}, label: {
Text(!self.editMode ? "Edit" : "Done")
})