Using Xcode 11.0, Beta 5.
I have a List driven from an array of model objects in an observed view model. Each item in the List has a NavigationLink with a destination detail view/view model that accepts the model as an argument.
The user can tap a bar button above the list to add a new item which is added to the view model's array and the List is therefore reloaded with the new item displayed.
The issue I cannot solve is how to select that new item in the list and therefore display the detail view without needing the user to select it manually. (This is an iPad app with split screen view, hence the reason to want to select it)
I've tried using a NavigationLink programatically, but can't seem to get anything to work. I looked at the selection argument for the List but that also requires the list to be in edit mode, so that's no good.
Any suggestions are very welcome!
The following solution uses the selection attribute of NavigationLink. One problem here is that only items currently visible get rendered, so selecting a row further down does nothing.
import SwiftUI
struct Item: Identifiable {
let id = UUID()
var title: String
var content: String
}
struct ItemOverview: View {
let item: Item
var body: some View {
Text(item.title)
}
}
struct ItemDetailsView: View {
let item: Item
var body: some View {
VStack{
Text(item.title).font(.headline)
Divider()
Text(item.content)
Spacer()
}
}
}
func randomString(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<length).map{ _ in letters.randomElement()! })
}
struct ListSelectionView: View {
#State var items: [Item] = [
Item(title: "Hello", content: "Content World"),
Item(title: "Hey", content: "Content Du"),
]
#State var selection: UUID? = nil
func createItem() {
let newItem = Item(title: randomString(length: 3), content: randomString(length: 10))
self.selection = newItem.id
self.items.append(newItem)
}
var body: some View {
VStack{
NavigationView{
List(items) { item in
NavigationLink(destination: ItemDetailsView(item: item), tag: item.id, selection: self.$selection, label: {
Text(item.title)
})
}
}
Button(action: {
self.createItem()
}) { Text("Add and select new item") }
Divider()
Text("Current selection2: \(String(selection?.uuidString ?? "not set"))")
}
}
}
A second problem is that changing the $selection makes Modifying state during view update appear.
Third problem is that after manual selection the shading stays on the same item until again changed by hand.
Result
Programmatic selection is not really usable for now if you want to select a link not initialized yet (not visible?).
Further ideas
One might look into tags a little bit more.
Another option could be paging where all items of the current page are visible.
One could also use list selection and show details based on that.
Related
To explain this behavior, I have built a simplified project that creates same behavior as my working project. Please keep in mind that my real project is much bigger and have more constraints than this one.
I have a simple struct with only an Int as property (named MyObject) and a model that stores 6 MyObject.
struct MyObject: Identifiable {
let id: Int
}
final class Model: ObservableObject {
let objects: [MyObject] = [
MyObject(id: 1),
MyObject(id: 2),
MyObject(id: 3),
MyObject(id: 4),
MyObject(id: 5),
MyObject(id: 6)
]
}
Please note that in my real project, Model is more complicated (loading from JSON).
Then I have a FavoritesManager that can add and remove a MyObject as favorite. It has a #Published array of ids (Int):
final class FavoritesManager: ObservableObject {
#Published var favoritesIds = [Int]()
func addToFavorites(object: MyObject) {
guard !favoritesIds.contains(object.id) else { return }
favoritesIds.append(object.id)
}
func removeFromFavorites(object: MyObject) {
if let index = favoritesIds.firstIndex(of: object.id) {
favoritesIds.remove(at: index)
}
}
}
My App struct creates Model and FavoritesManager and injects them in my first view:
#main
struct testAppApp: App {
#StateObject private var model = Model()
#StateObject private var favoritesManager = FavoritesManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
.environmentObject(favoritesManager)
}
}
}
Now I have few views that display my objects:
ContentView displays a link to ObjectsListView using NavigationLink and a FavoritesView. In my real project, ObjectsListView does not display all objects, only a selection (by category for example).
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
VStack {
NavigationLink(
destination: ObjectsListView(objects: model.objects), label: {
Text("List of objects")
}
)
FavoritesView()
.padding()
}
}
}
}
FavoritesView displays ids of current selected favorites. It observes FavoritesManager to update its body when a favorite is added/removed.
Important: it creates a new NavigationLink to favorites details view. If you remove this link, bug does not happen.
struct FavoritesView: View {
#EnvironmentObject var model: Model
#EnvironmentObject var favoritesManager: FavoritesManager
var body: some View {
HStack {
Text("Favorites:")
let favoriteObjects = model.objects.filter( { favoritesManager.favoritesIds.contains($0.id) })
ForEach(favoriteObjects) { object in
NavigationLink(destination: DetailsView(object: object), label: {
Text("\(object.id)")
})
}
}
}
}
ObjectsListView simply displays objects and makes a link to DetailsView.
struct ObjectsListView: View {
#State var objects: [MyObject]
var body: some View {
List {
ForEach(objects) { object in
NavigationLink(destination: DetailsView(object: object), label: { Text("Object \(object.id)")})
}
}
}
}
DetailsView displays object details and add a button to add/remove current object as favorite.
If you press the star, current object will be added as favorite (as expected) and FavoritesView will be updated to display/remove this new id (as expected).
But here is the bug: as soon as you press the star, current view (DetailsView) disappears and it goes back (without animation) to previous view (ObjectsListView).
struct DetailsView: View {
#EnvironmentObject var favoritesManager: FavoritesManager
#State var object: MyObject
var body: some View {
VStack {
HStack {
Text("You're on object \(object.id) detail page")
Button(action: {
if favoritesManager.favoritesIds.contains(object.id) {
favoritesManager.removeFromFavorites(object: object)
},
else {
favoritesManager.addToFavorites(object: object)
}
}, label: {
Image(systemName: favoritesManager.favoritesIds.contains(object.id) ? "star.fill" : "star")
.foregroundColor(.yellow)
})
}
Text("Bug is here: if your press the star, it will go back to previous view.")
.font(.caption)
.padding()
}
}
}
You can download complete example project here.
You may ask why I don't display objects list in ContentView directly. It is because of my real project. It displays objects classified by categories in lists. I have reproduce this behavior with ObjectsListView.
I also need to separated Model and FavoritesManager because in my real project I don't want that my ContentView's body is updated everytime a favorite is added/removed. That's why I have moved all favorites displaying in FavoritesView.
I believe that this issue happens because I add/remove NavigationLinks in current NavigationView. And it looks like it is reloading whole navigation hierarchy. But I can't figure out how to get expected result (staying in DetailsView when pressing star button) with same views hierarchy. I may do something wrong somewhere...
Add .navigationViewStyle(.stack) to your NavigationView in ContentView.
With that, your code works well for me, I can press the star in DetailsView, and it remains displayed. It does not
go back to the previous View. Using macos 12.2, Xcode 13.2,
targets ios 15.2 and macCatalyst 12.1. Tested on real devices.
Need help passing CoreData to another view after its saved to the database. I have a separate view to "Add Items" and the List View shows the items, sorted. With NavigationView I can tap to see each entry, but I want to .onTapGesture overlay a new View with the list items that are editable.
Struggling to figure this out.
Right now this works to see more detail of each list item, but I'd rather have it open a new view with an .onTapGesture but can't figure out how to pass the database item tapped to the new View and set it to edit.
NavigationView{
List {
ForEach(newItem) { newItems in
NavigationLink(destination: TapItemView(database: newItems)) {
DatabaseRowView(database: newItems)}
}
}.onDelete(perform: deleteItem)
}
?
An observation: in your code sample--"ForEach(newItem) { newItems in" should be "ForEach(newItems) { newItem in". In other words the collection goes in the ForEach parentheses and the single item precedes the "in". With that, I started with the Xcode-generated project with Core Data for this example (Xcode Version 13.1). Modifications are included in the code below.
#State var showEditView = false
#State var tappedItem: Item = Item()
var body: some View {
NavigationView {
List {
ForEach(items) { item in
Text(item.timestamp, formatter: itemFormatter)
.onTapGesture {
tappedItem = item
showEditView = true
}
}
.sheet(isPresented: $showEditView) {
EditView(item: $tappedItem)
}
}
}
}
Here's a possible receiving view for the tapped item:
struct EditView: View {
#Binding var item: Item
var body: some View {
NavigationView {
Form {
Section(header: Text("Date")) {
DatePicker("Date Selection",
selection: $item.timestamp,
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
}
}
}
}
I'm working on a SwitUI app that shows a list of items that I want to create/edit/delete. I've been working with Core Data, so I have a #FetchRequest in the view that retrieves the objects. Then a '+' button which creates a new object using the Core Data context, with a placeholder title, and adds it to the list. The user can then tap to edit an object in the list to add information to it using the editor view.
My question is, is there a way to programmatically open the editor view when a new object is created, passing in that same object?
I'm probably missing something simple, but I'm struggling to achieve this. I can't create the editor view in the parent view hierarchy, and show it by toggling a #State variable, as I don't have the newly created object at the time the View is instantiated.
Here's a bit of pseudocode to help illustrate more clearly:
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Object.title, ascending: true)],
animation: .default) private var objects: FetchedResults<Object>
#State var objectEditorIsActive: Bool = false
var body: some View {
List {
Button(action: {
addObject()
}, label: { Image(systemName: "plus") })
// List of existing objects, with a button to open ObjectEditor
// and pass in the corresponding object for editing.
NavigationLink(
destination: ObjectEditor(object: existingObject),
isActive: $objectEditorIsActive,
label: { ObjectView() })
}
}
func addObject() {
withAnimation {
let newObject = Object(context: viewContext)
newObject.title = "New Object"
try? viewContext.save()
// if this is successful, open ObjectEditor with newObject passed in.
// Via NavigationLink ideally, but in a sheet or any other method is fine.
}
}
So basically I want to open ObjectEditor and pass in newObject in one button press, rather than requiring the user to have to specifically select the newly created object every time.
Any help would be greatly appreciated!
Here is possible approach - the idea is to use dynamic binding to state property of newly created object and activate hidden navigation link programmatically.
Tested with Xcode 12.1 / iOS 14.1
#State private var newObject: Object?
private var isNewObject: Binding<Bool> {
Binding(get: { self.newObject != nil }, // activate when state is set
set: { _ in self.newObject = nil }) // reset back
}
...
List {
Button(action: {
addObject()
}, label: { Image(systemName: "plus") })
// List of existing objects, with a button to open ObjectEditor
// and pass in the corresponding object for editing.
}
.background(
NavigationLink(
destination: ObjectEditor(object: newObject),
isActive: isNewObject, // << activated programmatically !!
label: { EmptyView() })
)
...
func addObject() {
withAnimation {
let newObject = Object(context: viewContext)
newObject.title = "New Object"
if let _ = try? viewContext.save() {
self.newObject = newObject // << here !!
}
}
}
I want to basically make didSelectRow like UITableView in SwiftUI.
This is the code:
struct ContentView: View {
var testData = [Foo(name: "1"),
Foo(name: "2"),
Foo(name: "3"),
Foo(name: "4"),
Foo(name: "5")]
#State var selected: Foo?
var body: some View {
NavigationView {
VStack {
List(testData, id: \.name, selection: $selected) { foo in
HStack {
Text(foo.name)
}
}.navigationBarTitle("Selected \(selected?.name ?? "")")
Button("Check:") {
print(selected?.name)
}
}
}
}
I was thought if I click the cell then selected should contains the selected value, but it's not. The selected has no value. And the cell not clickable.
So I added a Button.
NavigationView {
VStack {
List(testData, id: \.name, selection: $selected) { foo in
HStack {
Text(foo.name)
Button("Test") {
print("\(foo) is selected.")
print(selected?.name)
}
}
}.navigationBarTitle("Selected \(selected?.name ?? "")")
Button("Check:") {
print(selected?.name)
}
}
Now, click works, but actually foo is the one I want there's no need selected why selection of the List is here.
Not sure anything I missed. Should the Button is necessary for the List "didSelectRow"? thanks!
EDIT
After a bit more investigation, my current conclusion is:
For single selections, no need call List(.. selection:). But you have to use Button or OnTapGesture for clickable.
List(.. selection:) is only for edit mode, which is multiple selection, as you can see the selection: needs a set. My example should be
#State var selected: Set<Foo>?
On iOS selection works in Edit mode by design
/// Creates a list with the given content that supports selecting multiple
/// rows.
///
>> /// On iOS and tvOS, you must explicitly put the list into edit mode for
>> /// the selection to apply.
///
/// - Parameters:
/// - selection: A binding to a set that identifies selected rows.
/// - content: The content of the list.
#available(watchOS, unavailable)
public init(selection: Binding<Set<SelectionValue>>?, #ViewBuilder content: () -> Content)
so you need either add EditButton somewhere, or activate edit mode programmatically, like
List(selection: $selection) {
// ... other code
}
.environment(\.editMode, .constant(.active)) // eg. persistent edit mode
Update: Here is some demo of default SwiftUI List selection
struct DemoView: View {
#State private var selection: Set<Int>?
#State private var numbers = [0,1,2,3,4,5,6,7,8,9]
var body: some View {
List(selection: $selection) {
ForEach(numbers, id: \.self) { number in
VStack {
Text("\(number)")
}
}.onDelete(perform: {_ in})
}
.environment(\.editMode, .constant(.active))
}
}
I have a SwiftUI ScrollView with an HStack and a ForEach inside of it. The ForEach is built off of a Published variable from an ObservableObject so that as items are added/removed/set it will automatically update in the view. However, I'm running into multiple problems:
If the array starts out empty and items are then added it will not show them.
If the array has some items in it I can add one item and it will show that, but adding more will not.
If I just have an HStack with a ForEach neither of the above problems occur. As soon as it's in a ScrollView I run into the problems.
Below is code that can be pasted into the Xcode SwiftUI Playground to demonstrate the problem. At the bottom you can uncomment/comment different lines to see the two different problems.
If you uncomment problem 1 and then click either of the buttons you'll see just the HStack updating, but not the HStack in the ScrollView even though you see init print statements for those items.
If you uncomment problem 2 and then click either of the buttons you should see that after a second click the the ScrollView updates, but if you keep on clicking it will not update - even though just the HStack will keep updating and init print statements are output for the ScrollView items.
import SwiftUI
import PlaygroundSupport
import Combine
final class Testing: ObservableObject {
#Published var items: [String] = []
init() {}
init(items: [String]) {
self.items = items
}
}
struct SVItem: View {
var value: String
init(value: String) {
print("init SVItem: \(value)")
self.value = value
}
var body: some View {
Text(value)
}
}
struct HSItem: View {
var value: String
init(value: String) {
print("init HSItem: \(value)")
self.value = value
}
var body: some View {
Text(value)
}
}
public struct PlaygroundRootView: View {
#EnvironmentObject var testing: Testing
public init() {}
public var body: some View {
VStack{
Text("ScrollView")
ScrollView(.horizontal) {
HStack() {
ForEach(self.testing.items, id: \.self) { value in
SVItem(value: value)
}
}
.background(Color.red)
}
.frame(height: 50)
.background(Color.blue)
Spacer()
Text("HStack")
HStack {
ForEach(self.testing.items, id: \.self) { value in
HSItem(value: value)
}
}
.frame(height: 30)
.background(Color.red)
Spacer()
Button(action: {
print("APPEND button")
self.testing.items.append("A")
}, label: { Text("APPEND ITEM") })
Spacer()
Button(action: {
print("SET button")
self.testing.items = ["A", "B", "C"]
}, label: { Text("SET ITEMS") })
Spacer()
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = UIHostingController(
// problem 1
rootView: PlaygroundRootView().environmentObject(Testing())
// problem 2
// rootView: PlaygroundRootView().environmentObject(Testing(items: ["1", "2", "3"]))
)
Is this a bug? Am I missing something? I'm new to iOS development..I did try wrapping the actual items setting/appending in the DispatchQueue.main.async, but that didn't do anything.
Also, maybe unrelated, but if you click the buttons enough the app seems to crash.
Just ran into the same issue. Solved with empty array check & invisible HStack
ScrollView(showsIndicators: false) {
ForEach(self.items, id: \.self) { _ in
RowItem()
}
if (self.items.count == 0) {
HStack{
Spacer()
}
}
}
It is known behaviour of ScrollView with observed empty containers - it needs something (initial content) to calculate initial size, so the following solves your code behaviour
#Published var items: [String] = [""]
In general, in such scenarios I prefer to store in array some "easy-detectable initial value", which is removed when first "real model value" appeared and added again, when last disappears. Hope this would be helpful.
For better readability and also because the answer didn't work for me. I'd suggest #TheLegend27 answer to be slightly modified like this:
if self.items.count != 0 {
ScrollView(showsIndicators: false) {
ForEach(self.items, id: \.self) { _ in
RowItem()
}
}
}