I'm trying to create a 3-column layout in SwiftUI.
The first column is a LazyVGrid of selectable items. The selection then impacts a list of items in a content view (second column) which also isn't a SwiftUI list but a VStack + other views in a scrollview. Selecting items on that column impacts the detail view.
I got it all to work on the iPad, but this is because the iPad displays multiple columns at a time and NavigationSplitView supports gestures on the iPad as well as column visibility settings in code.
The problem is that I can't find a way to programmatically navigate from one column to another on the iPhone as it doesn't seem to respond to column visibility bindings.
I initially had it working with navigation links for each item on my grid where the destination was set to a view, but the code smelled pretty bad.
Eventually, I came up with the code below. In the first column I have my grid view which has a custom onSelect modifier that I trigger whenever an item is selected. That's where I'm trying to change column visibility. I tried setting vm.navigationColumnVisibility = .detailOnly, but the iPhone seems to ignore it.
I was able to get it to work exactly as expected by changing the grid to a List. The selection property/Binding in the List view seemed to trigger navigation on the iPhone. However, that's not the desired UI/UX.
Any advice on how to trigger navigation between columns programatically on the iPhone or a better way to adapt this code to achieve the described UI/UX?
struct JNavSplitView: View {
#EnvironmentObject var vm: JNavSplitViewModel
#State private var book: Book? = nil
#State private var entry: Entry? = nil
var sheet: some View {
#if os(macOS)
JEntryCreateFormMacOS(book: book)
#else
JEntryCreateForm(book: book)
.onCreate { entry in
self.entry = entry
}
#endif
}
var body: some View {
NavigationSplitView(columnVisibility: $vm.navigationColumnVisibility) {
BooksGridView(selected: $book)
.onSelect { book in
self.entry = book.getEntries(forDate: Date()).first
}
} content: {
BookEntriesView(book: book, selected: $entry)
} detail: {
PageCollectionView(entry: $entry)
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
#if os(macOS)
.toolbarBackground(Color("Purple 1000"), for: .windowToolbar)
#endif
.environmentObject(vm)
.sheet(isPresented: $vm.presentNewEntryForm, content: {
sheet
})
}
}
Related
I am new to Xcode, and facing some issues while creating a table view.
Problem: In iOS 14 Plus simulator, only 1 columns is shown - "Category" and without header. The entire column "Total" is completely missing. However, when I switch the simulation to ipad, everything is working fine. Really appreciate if someone could help to resolve this issue.
struct OverallObj: Identifiable {
var category: String
var total: Int
var id: String {
category
}
static var summary: [OverallObj] {
[
OverallObj(category: "1st", total: 1000),
OverallObj(category: "2nd", total: 1000)
]
}
}
var body: some View {
Table(overallView) {
TableColumn("Category", value: \.category)
TableColumn("Total") { overallObj in
Text("\(overallObj.total)")
}
}
}
I am expecting the table to show 2 columns - "Category" and "Total" with headers
The problem in your specific case is caused by the compact horizontal size on iOS.
From the official documentation:
MacOS and iPadOS support SwiftUI tables. On iOS, and in other
situations with a compact horizontal size class, tables don’t show
headers and collapse all columns after the first. If you present a
table on iOS, you can customize the table’s appearance by implementing
compact-specific logic in the first column.
You can implement compact-specific logic in the first column of a table in SwiftUI by using the environment modifier and checking the sizeCategory environment value.If the size category is compact, you can return a compact version of the view for the first column with all the data you need to display, otherwise return the normal version of the view.
Here is an example:
#Environment(\.horizontalSizeClass) var sizeCategory
var body: some View {
Table(overallView) {
TableColumn("Category") { item in
if (sizeCategory == .compact) {
// Return compact version of the view
Text("Compact")
} else {
// Return normal version of the view
Text("Normal")
}
}
TableColumn("Total") { overallObj in
Text("\(overallObj.total)")
}
}
Using tables on different platforms
macOS and iPadOS support SwiftUI tables. On iOS, and in other situations with a compact horizontal size class, tables don’t show headers and collapse all columns after the first. If you present a table on iOS, you can customize the table’s appearance by implementing compact-specific logic in the first column.
Source: https://developer.apple.com/documentation/SwiftUI/Table
The sample app is a default SwiftUI + Core Data template with two modifications. Detail for Item is a separate view where the user can change the timestamp. And sectioned fetch request is used as opposed to a regular one.
#SectionedFetchRequest(
sectionIdentifier: \.monthAndYear, sortDescriptors: [SortDescriptor(\.timestamp)]
) private var items: SectionedFetchResults<String, Item>
var body: some View {
NavigationView {
List(items) { section in
Section(header: Text(section.id)) {
ForEach(section) { item in
NavigationLink {
ItemDetail(item: item)
} label: {
Text(item.timestamp!.formatted())
}
}
}
}
}
}
struct ItemDetail: View {
#ObservedObject var item: Item
#State private var isPresentingDateEditor = false
var body: some View {
Text("Item at \(item.timestamp!.formatted())")
.toolbar {
ToolbarItem(placement: .principal) {
Button(item.timestamp!.formatted()) {
isPresentingDateEditor = true
}
}
}
.sheet(isPresented: $isPresentingDateEditor) {
if let date = Binding($item.timestamp) {
DateEditor(date: date)
}
}
}
}
The problem arises when the user changes the timestamp on an Item to a different month. The detail view dismisses behind the model view arbitrarily. However, the issue is not present if the user changes the timestamp to a day within the same month. It does not matter whether we use a sheet or a full-screen cover. When I was debugging this I noticed that any change of the NSManagedObject subclass instance that will change the section in which it is displayed will dismiss the detail view arbitrarily. I’m expecting to stay on the detail view even if I change the timestamp to a different month.
What is the root cause of this issue and how to fix it?
I think it's because the NavigationLink is no longer active because it has moved to a different section so it has a different structural identity and has defaulted back to inactive. Structural identity is explained in WWDC 2021 Demystify SwiftUI. I reported this as bug FB9977102 hopefully they fix it.
Another major problem with NavigationLink that it doesn't work when offscreen. E.g. in your sample code add lots of rows to fill up more than the screen. Scroll to top, wait one minute (so the picked updates), select first row, adjust the time to current time which will move the row to last in the list and off screen. You'll notice the NavigationLink has deactivated. People face this problem when trying to implement deep links and they can't activate a NavigationLink that is in a row that is off screen.
Update 15th Nov 2022: Apple replied to my feedback FB9977102 "this can be resolved by using the new Navigation APIs to avoid the view identity issues described." NavigationStack seems ok but NavigationSplitView has a row selection bug, see my screen-capture.
I'm currently working on a project that uses SwiftUI. I was trying to use List to display a list of let's say 10 items.
Does List supports reusability just like the UITableview?
I went through multiple posts and all of them says that List supports reusability: Does the List in SwiftUI reuse cells similar to UITableView?
But the memory map of the project says something else. All the Views in the List are created at once and not reused.
Edit
Here is how I created the List:
List {
Section(header: TableHeader()) {
ForEach(0..<10) {_ in
TableRow()
}
}
}
TableHeader and TableRow are the custom views created.
List actually provides same technique as UITableView for reusable identifier. Your code make it like a scroll view.
The proper way to do is providing items as iterating data.
struct Item: Identifiable {
var id = UUID().uuidString
var name: String
}
#State private var items = (1...1000).map { Item(name: "Item \($0)") }
...
List(items) {
Text($0.name)
}
View hierarchy debugger shows only 17 rows in memory
I'm following this SwiftUI tutorial and downloaded the project files.
I built and ran the complete project without any modifications. In the app, if I:
Toggle "Show Favorites Only" on in the list view
Tap into the "Turtle Rock" or "Chilkoot Trail" detail view
In the detail view, I toggle the favorite button (a yellow star icon)
The screen will jump back to the list view by itself.
But if I tap into the detail view of the last item ("St. Mary Lake") in the list view, I can toggle the yellow star button on and off and still stay in the same detail view.
Can anyone explain this behavior? What do I need to do to stay in the detail view without being forced to navigate back to the list view?
Well, actually it is SwiftUI defect, the View being out of view hierarchy must not be refreshed (ie. body called) - it should be updated right after next appearance. (I submitted feedback #FB7659875, and recommend to do the same for everyone affected - this is the case when duplicates are better)
Meanwhile, below is possible temporary workaround (however it will continue work even after Apple fix the issue, so it is safe). The idea is to use local view state model as intermediate between view and published property and make it updated only when view is visible.
Provided only corrected view to be replaced in mentioned project.
Tested with Xcode 11.4 / iOS 13.4 - no unexpected "jump back"
struct LandmarkList: View {
#EnvironmentObject private var userData: UserData
#State private var landmarks = [Landmark]() // local model
#State private var isVisible = false // own visibility state
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}
ForEach(landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark)
.environmentObject(self.userData)
) {
LandmarkRow(landmark: landmark)
}
}
}
}
.onReceive(userData.$landmarks) { array in // observe external model
if self.isVisible {
self.landmarks = array // update local only if visible
}
}
.onAppear {
self.isVisible = true // track own state
self.landmarks = self.userData.landmarks
}
.onDisappear { self.isVisible = false } // track own state
.navigationBarTitle(Text("Landmarks"))
}
}
}
this happens because in the "main" list you toggled to "show only favorites". then you change in the detail the favorites (so it is no favorite landmark anymore) and because in swiftui the source of truth was changed (favorite) this item was removed from the main list and so it cannot be shown in the detail anymore because it is no member of the main list anymore, so the detail view just navigates back and show the favorite items only.
I'm using a NavigationLink inside of a ForEach in a List to build a basic list of buttons each leading to a separate detail screen.
When I tap on any of the list cells, it transitions to the detail view of that cell but then immediately pops back to the main menu screen.
Not using the ForEach helps to avoid this behavior, but not desired.
Here is the relevant code:
struct MainMenuView: View {
...
private let menuItems: [MainMenuItem] = [
MainMenuItem(type: .type1),
MainMenuItem(type: .type2),
MainMenuItem(type: .typeN),
]
var body: some View {
List {
ForEach(menuItems) { item in
NavigationLink(destination: self.destination(item.destination)) {
MainMenuCell(menuItem: item)
}
}
}
}
// Constructs destination views for the navigation link
private func destination(_ destination: ScreenDestination) -> AnyView {
switch destination {
case .type1:
return factory.makeType1Screen()
case .type2:
return factory.makeType2Screen()
case .typeN:
return factory.makeTypeNScreen()
}
}
If you have a #State, #Binding or #ObservedObject in MainMenuView, the body itself is regenerated (menuItems get computed again) which causes the NavigationLink to invalidate (actually the id change does that). So you must not modify the menuItems arrays id-s from the detail view.
If they are generated every time consider setting a constant id or store in a non modifying part, like in a viewmodel.
Maybe I found the reason of this bug...
if you use iOS 15 (not found iOS 14),
and you write the code NavigationLink to go to same View in different locations in your projects, then this bug appear.
So I simply made another View that has different destination View name but the same contents... then it works..
you can try....
sorry for my poor English...