3

I am trying to implement the ability to move rows in a hierarchical SwiftUI List via drag+drop. My List is build with recursive ForEach loops:

import SwiftUI

struct FileItem: Hashable, Identifiable, CustomStringConvertible {
    var id: Self { self }
    var name: String
    var children: [FileItem]
    var description: String {
        return children.isEmpty ? " \(name)" : " \(name)"
    }
}

struct ContentView: View {
    
    let fileHierarchyData: [FileItem] = [
        FileItem(name: "users", children:
                    [FileItem(name: "user1234", children:
                                [FileItem(name: "Photos", children:
                                            [FileItem(name: "photo001.jpg", children: []),
                                             FileItem(name: "photo002.jpg", children: [])]),
                                 FileItem(name: "Movies", children:
                                            [FileItem(name: "movie001.mp4", children: [])]),
                                 FileItem(name: "Documents", children: [])
                                ]),
                     FileItem(name: "newuser", children:
                                [FileItem(name: "Documents", children: [])
                                ])
                    ]),
        FileItem(name: "private", children: [])
    ]
    
    var body: some View {
        List {
            ForEach(fileHierarchyData) { item in
                RowView(item: item)
            }
        }
    }
}

struct RowView: View {
    var item: FileItem
    
    var body: some View {
        let children = item.children
        
        DisclosureGroup(content: {
            ForEach(0..<children.count, id: \.self) { idx in
                let child = children[idx]
                if (child.children.isEmpty) {
                    Text(child.description)
                } else {
                    RowView(item: child)
                }
            }
            .onMove(perform: move)
        }, label: {
            Text(item.description)
        })
    }
}

func move(from source: IndexSet, to destination: Int) {
    print("FROM: \(source)")
    print("TO: \(destination)")
}

One reason I use nested ForEach loops us so I can make use of the onMove function to specify how to reorder my data. However, as implemented, I can only call that function within the same hierarchy level of my data, not in between hierarchies. For example, I could reorder user1234 and newuser from the fileHierarchyData array, but I could not bring Photos to the same level as these two. Hence my question: How can I make the reordering work between hierarchy levels? I currently build my app for macOS in Xcode 14.1 + Swift 5.

DaPhil
  • 1,509
  • 4
  • 25
  • 47
  • I assume when you want to reorder `user1234` you want all the children to be dragged along with it, rather than them all being separate rows? If so, your question is very similar to [my one](https://stackoverflow.com/q/69066989/9607863). And not to discourage you by _too_ much... but this has been my main blocker for my own app. That blocker being **over a year**. I need as much help as you here, please let me know if you find anything. – George Dec 07 '22 at 16:43
  • FWIW, I'm currently building my own view from scratch. – George Dec 07 '22 at 16:53
  • 1
    @George You assumption is correct. I would want that too. I thought of it as a second step... – DaPhil Dec 08 '22 at 10:50
  • Done a bit of ii on this topic. onMove for multiple ForEach loops doesn't/can't work. It just has to be singular. One approach to acheiving that is by flattening the loops; my experiment with this is over here https://github.com/shufflingB/swiftui-macos-tree-list-demo/tree/list_and_onMove_based - it works, but feels brittle and not very native. An alternative is to use drag and drop, the corresponding code for that experiment is here https://github.com/shufflingB/swiftui-macos-tree-list-demo/tree/main , for my money that's a better option, far closer to production quality. – shufflingb Dec 12 '22 at 23:35
  • NSOutlineView has been used for this stuff. I just found there's a SwiftUI wrapper project: [link](https://github.com/Sameesunkaria/OutlineView) Still, this library doesn't cover all features of UIView's one and also drag n drops but it's on working in real time! You can find drag n drop PR is on progress and I believe it will come out very soon. – ST K Jan 18 '23 at 06:48
  • @STK I guess it depends on use case, but I'm looking for an iOS version (I'm not the OP of this question, I just had a similar question). Looks like DaPhil might be using only macOS though! – George Jan 18 '23 at 15:46

1 Answers1

0

I had been working on this problem a while ago. Your question brought me back to it and I think I now have a viable proof of concept.

Unfortunately so far it only works for macOS.

class FileSystem: ObservableObject {
    
    @Published var files: [FileItem]
    
    init() {
        files = [
            FileItem(name: "users", children:
                        [FileItem(name: "user1234", children:
                                    [FileItem(name: "Photos", children:
                                                [FileItem(name: "photo001.jpg", children: nil),
                                                 FileItem(name: "photo002.jpg", children: nil)]),
                                     FileItem(name: "Movies", children:
                                                [FileItem(name: "movie001.mp4", children: nil)]),
                                     FileItem(name: "Documents", children: [])
                                    ]),
                         FileItem(name: "newuser", children:
                                    [FileItem(name: "Documents", children: [])
                                    ])
                        ]),
            FileItem(name: "private", children: nil)
        ]
    }
    
    class FileItem: Identifiable, CustomStringConvertible {
        
        internal init(name: String, children: [FileSystem.FileItem]? = nil) {
            self.name = name
            self.children = children
        }
        
        let id = UUID()
        var name: String
        var children: [FileItem]?
        var description: String {
            if children == nil { return " \(name)" }
            return children!.isEmpty ? " \(name)" : " \(name)"
        }
    }
    
    func findFile(inFiles: [FileItem]? = nil, id: UUID?) -> FileItem? {
        guard let id else { return nil }
        
        for f in inFiles ?? self.files {
            if f.id == id {
                return f
            } else {
                if let children = f.children {
                    if let f = findFile(inFiles: children, id: id) {
                        return f
                    }
                }
            }
        }
        return nil
    }
    
    func deleteFile(parent: FileItem? = nil, id: UUID) {

        if parent == nil {
            // is file on this level?
            if let i = self.files.firstIndex(where: { $0.id == id }) {
                self.objectWillChange.send()
                self.files.remove(at: i)
                return
            }
            // go through children recursively
            for file in self.files {
                deleteFile(parent: file, id: id)
            }
            
        } else {
            // is file on this level?
            if let i = parent?.children?.firstIndex(where: { $0.id == id }) {
                self.objectWillChange.send()
                parent?.children?.remove(at: i)
                return
            }
            // go through children recursively
            for file in parent?.children ?? [] {
                deleteFile(parent: file, id: id)
            }
        }
    }
}



struct ContentView: View {
    
    @StateObject var filesystem = FileSystem()
    
    var body: some View {
        List {
            OutlineGroup($filesystem.files, children: \.children) { $file in
                FileCell(file: $file)
            }
        }
        .environmentObject(filesystem)
    }
}


struct FileCell: View {
    
    @Binding var file: FileSystem.FileItem
    
    @State private var isTargeted = false
    @EnvironmentObject var fileSystem: FileSystem
    
    var body: some View {
        
        HStack {
            Text(file.description)
            Spacer()
        }
        // drag target background
        .background(
            RoundedRectangle(cornerRadius: 8).fill(.orange)
                .frame(height: 24)
                .opacity(isTargeted ? 0.5 : 0)
        )
        
        // drag and drop
        
        .onDrag {
            return NSItemProvider(object: file.id.uuidString as NSString)
        }
        
        .dropDestination(for: String.self) { items, location in
            if let item = items.first,
                let id = UUID(uuidString: item),
                let found = fileSystem.findFile(id: id) {
                
                // remove child at old position
                fileSystem.deleteFile(id: id)
                
                // add child at new position
                if self.file.children == nil {
                    self.file.children = [found]
                } else {
                    self.file.children!.append(found)
                }
                
                return true
            }
            return false
        } isTargeted: {
            isTargeted = $0
        }
        
    }
}
ChrisR
  • 9,523
  • 1
  • 8
  • 26