How to use Transferable for Table Rows in SwiftUI - ios

With WWDC 2022, Apple introduced the Transferable protocol to support Drag & Drop operations in an easy way. How can I use this new technique (in combination with the new draggable and dropDestination modifiers) for SwiftUI tables (not lists)?
The TableRowContent does not support the draggable and dropDestination modifiers. Also, when applying the modifiers directly to the views in the TableColumns, the drag / drop operation will only work on for that specific cell, and not the entire row. When adding the modifiers to all cells, it still does not work when dragging e.g. in an empty space inside of a row.
struct Item: Identifiable, Codable, Transferable {
let id = UUID()
let text: String
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .text)
}
}
struct Test: View {
var body: some View {
Table {
TableColumn("Column 1") { item in
Text(item.text)
.draggable(item) // This does only work when dragging not in the space between two columns
}
TableColumn("Column 2") { item in
Text(item.text)
.draggable(item) // This does only work when dragging not in the space between two columns
}
} rows: {
ForEach([Item(text: "Hello"), Item(text: "World")]) { item in
TableRow(item)
.draggable(item) // This causes a compile-time error
.itemProvider { ... } // This does not work with Transferable and thus support my use case
}
}
}
}
I want a similar behavior as the itemProvider modifier and the recently added contextMenu modifier on the TableRowContent , which allow the respective operation on the whole table row. I cannot use itemProvider, since it requires to return an NSItemProvider, which does not support my use case of dragging a file from a network drive to the Mac hard drive.

I found out that you can register a Transferable object in the NSItemProvider:
TableRow(item)
.itemProvider {
let provider = NSItemProvider()
provider.register(item)
return provider
}
Unfortunately the itemProvider closure is already called when the dragging session starts instead of when the drop was performed. But I guess this problem is a different question to answer.

Related

Swift table only show 1 column

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

SwiftUI: Incorrect UI when switching to a new list from an old one with swipe button shown

The issue can be reproduced consistently with the code below. Xcode 13.3 + iOS 15.4 (both are latest).
enum ListID: String, CaseIterable, Hashable, Identifiable {
case list1 = "List1"
case list2 = "List2"
var id: Self {
self
}
}
struct ContentView: View {
#State var listID: ListID = .list1
var body: some View {
VStack {
// 1) Picker
Picker(selection: $listID) {
ForEach(ListID.allCases) { id in
Text(id.rawValue)
}
} label: {
Text("Select a list")
}
.pickerStyle(.segmented)
// 2) List
switch listID {
case .list1:
createList(Array(1...2), id: .list1)
case .list2:
createList(Array(101...102), id: .list2)
}
}
}
#ViewBuilder func createList(_ itemValues: [Int], id: ListID) -> some View {
List {
ForEach(itemValues, id:\.self) { value in
Text("\(value)")
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("Edit") {
// do nothing
}
.tint(.blue)
}
}
}
.id(id)
}
}
Steps to reproduce the issue:
start up the app. See picture 1.
swipe item 1 in list 1, keep the "Edit" button untouched (don't click on it). See picture 2
then select list 2 in the picker. You should see there is an extra space before the items in the list. What's more, all list items are not swipable any more. See picture 3.
then select list 1 in the picker. It has the same issue. See picture 4.
The issue is not specific to picker. For example, it can be reproduced if we replace picker with a set of buttons. I believe the issue occurs only if the old list is destroyed in SwiftUI view hierarchy. From my understanding of structured identity in SwiftUI, list 1 and list 2 are considered separate views in SwiftUI view hiearch. So it's not clear how they could affect each other. The only reason I can guess is that, while list 1 and list 2 are considered separate virtual views, SwiftUI actually use the same physical view for them (e.g., for performance purpose, etc.). So it seems a SwiftUI bug to me.
Thinking along that line, I can work round the issue by not destroying lists:
ZStack {
createList(Array(1...2), id: .list1)
.opacity(listID == .list1 ? 1 : 0)
createList(Array(101...102), id: .list2)
.opacity(listID == .list2 ? 1 : 0)
}
This works perfectly in this specific example, but unfortunately it's not scalable. For example, in my calendar app when user clicks on a date in the calendar, I'd like to show a list of events on the date (I'd like to use different list for different date. I do so by calling id() to set different id to each list). There isn't an obvious/elegant way to apply the above work around in this case.
So I wonder if anyone knows how to work around the issue in a more general way? Thanks.
I end up working around the issue by using a single virtual view for different lists. To that end, I need to move List outside switch statement (otherwise SwiftUI's structured identity mechanism would consider the two lists as different ones).
The workaround works reliably in my testing (including testing in my actual app). It's clean and general. I prefer to assigning a different id to each list because I think it's clean and better in architecture, but unfortunately it's not usable until Apple fixes the bug. I have submitted FB9976079 about this issue.
I'll keep my question open and welcome anyone leave your answer or comments.
enum ListID: String, CaseIterable, Hashable, Identifiable {
case list1 = "List1"
case list2 = "List2"
var id: Self {
self
}
}
struct ContentView: View {
#State var listID: ListID = .list1
var body: some View {
VStack {
// 1) Picker
Picker(selection: $listID) {
ForEach(ListID.allCases) { id in
Text(id.rawValue)
}
} label: {
Text("Select a list")
}
.pickerStyle(.segmented)
// 2) List
List {
switch listID {
case .list1:
createSection(Array(1...2), id: .list1)
case .list2:
createSection(Array(101...105), id: .list2)
}
}
}
}
// Note: the id param is not used as List id.
#ViewBuilder func createSection(_ itemValues: [Int], id: ListID) -> some View {
Section {
ForEach(itemValues, id:\.self) { value in
Text("\(value)")
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("Edit") {
// do nothing
}
.tint(.blue)
}
}
}
.id(id)
}
}

Reusability support in List in SwiftUI

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

SwiftUI: NavigationLink pops immediately if used within ForEach

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...

How to create an instance of an object in SwiftUI without duplication?

This is the next part of that question.
I've got the follow code.
The initial view of the app:
struct InitialView : View {
var body: some View {
Group {
PresentationButton(destination: ObjectsListView()) {
Text("Show ListView")
}
PresentationButton(destination: AnotherObjectsListView()) {
Text("Show AnotherListView")
}
}
}
}
The list view of the objects:
struct ObjectsListView : View {
#Environment(\.myObjectsStore.objects) var myObjectsStores: Places
var body: some View {
Group {
Section {
ForEach(myObjectsStore.objects) { object in
NavigationLink(destination: ObjectDetailView(object: object)) {
ObjectCell(object: object)
}
}
}
Section {
// this little boi
PresentationButton(destination: ObjectDetailView(objectToEdit: MyObject(store: myObjectsStore))) {
Text("Add New Object")
}
}
}
}
}
The detail view:
struct ObjectsDetailView : View {
#Binding var myObject: MyObject
var body: some View {
Text("\(myObject.title)")
}
}
So the problem is quite complex.
The ObjectsListView creates instance of the MyObject(store: myObjectsStore) on itself initialization while computing body.
The MyObject object is setting its store property on itself initialization, since it should know is it belongs to myObjectsStore or to anotherMyObjectsStore.
The myObjectsStore are #BindableObjects since their changes are managing by SwiftUI itself.
So this behavior ends up that I've unexpected MyObject() initializations since the Views are computing itself. Like:
First MyObject creates on the ObjectsListView initialization.
Second MyObject creates on its PresentationButton pressing (the expected one).
Third (any sometimes comes even fourth) MyObject creates on dismissing ObjectsDetailView.
So I can't figure what pattern should I use this case to create only one object?
The only thing that I'd come to is to make the follow code:
struct ObjectsListView : View {
#Environment(\.myObjectsStore.objects) var myObjectsStores: Places
#State var buttonPressed = false
var body: some View {
Group {
if buttonPressed {
ObjectDetailView(objectToEdit: MyObject(store: myObjectsStore))
} else {
Section {
ForEach(myObjectsStore.objects) { object in
NavigationLink(destination: ObjectDetailView(object: object)) {
ObjectCell(object: object)
}
}
}
Section {
Button(action: {
self.buttonPressed.toggle()
}) {
Text("Add New Object")
}
}
}
}
}
}
Which simply redraw ObjectsListView to detail view conditionally. But it's completely out of iOS guidelines. So how to create the Only One object for another view in SwiftUI?
UPD:
Here's the project that represents the bug with Object duplication.
I'm still have no idea why the objects are duplicating in this case. But at least I know the reason yet. And the reason is this line:
#Environment(\.myObjectsStore.objects) var myObjectsStores: Places
I've tried to share my model with this wrapper to make it available in every single view (including Modal one) without passing them as an arg to the new view initializer, which are unavailable by the other ways, like #EnvironmentObject wrapper. And for some reason #Environment(\.keyPath) wrapper makes duplications.
So I'd simply replace all variables from Environment(\.) to ObjectBinding and now everything works well.
I've found the solution to this.
Here's the project repo that represents the bug with Object duplication and the version that fix this. I'm still have no idea how objects have been duplicate in that case. But I figured out why. It happens because this line:
#Environment(\.myObjectsStore.objects) var myObjectsStores: MyObjectsStore
I've used #Environment(\.key) to connect my model to each view in the navigation stack including Modal one, which are unavailable by the other ways provided in SwiftUI, e.g.: #State, #ObjectBinding, #EnvironmentObject. And for some reason #Environment(\.key) wrapper produce these duplications.
So I'd simply replace all variables from #Environment(\.) to #ObjectBinding and now almost everything works well.
Note: The code is in the rep is still creates one additional object by each workflow walkthrough. So it creates two objects totally instead of one. This behavior could be fixed by way provided in this answer.

Resources