FaiChou
FaiChou

Reputation: 867

how to make the recursive view the same width?

I want to make a recursive view like this:

good

But what I have done is like this:

bad

It's a tvOS application, the sample code is:

struct MainView: View {
    @State private var selectedItem: ListItem?
    var body: some View {
        VStack {
            RecursiveFolderListView(fileId: "root", selectedItem: $selectedItem)
        }
    }
}
struct RecursiveFolderListView: View {
    @EnvironmentObject var api: API
    var fileId: String
    @Binding var selectedItem: ListItem?
    @State private var currentPageSelectedItem: ListItem?
    @State private var list: [ListItem]?
    @State private var theId = 0
    var body: some View {
        HStack {
            if let list = list, list.count > 0 {
                ScrollView(.vertical) {
                    ForEach(list, id: \.self) { item in
                        Button {
                            selectedItem = item
                            currentPageSelectedItem = item
                        } label: {
                            HStack {
                                Text(item.name)
                                    .font(.callout)
                                    .multilineTextAlignment(.center)
                                    .lineLimit(1)
                                Spacer()
                                if item.fileId == selectedItem?.fileId {
                                    Image(systemName: "checkmark.circle.fill")
                                        .resizable()
                                        .scaledToFit()
                                        .frame(width: 30, height: 30)
                                        .foregroundColor(.green)
                                }
                            }
                            .frame(height: 60)
                        }
                    }
                }
                .focusSection()
                .onChange(of: currentPageSelectedItem) { newValue in
                    if list.contains(where: { $0 == newValue }) {
                        theId += 1
                    }
                }
            } else {
                HStack {
                    Spacer()
                    Text("Empty")
                    Spacer()
                }
            }
            if let item = currentPageSelectedItem, item.fileId != fileId {
                RecursiveFolderListView(fileId: item.fileId, selectedItem: $selectedItem)
                    .id(theId)
            }
        }
        .task {
            list = try? await api.getFiles(parentId: fileId)
        }
    }
}

It's a list view, and when the user clicks one item in the list, it will expand the next folder list to the right. The expanded lists and the left one will have the same width.

I think it needs Geometryreader to get the full width, and pass down to the recursive hierarchy, but how to get how many views in the recursive logic?

I know why my code have this behavior, but I don't know how to adjust my code, to make the recursive views the same width.

Upvotes: 0

Views: 121

Answers (2)

FaiChou
FaiChou

Reputation: 867

Although rob's answer is perfect, I want to share another approach.

class SaveToPageViewModel: ObservableObject {
    @Published var fileIds = [String]()
    func tryInsert(fileId: String, parentFileId: String?) {
        if parentFileId == nil {
            fileIds.append(fileId)
        } else if fileIds.last == parentFileId {
            fileIds.append(fileId)
        } else if fileIds.last == fileId {
            // do noting, because this was caused by navigation bug, onAppear called twice
        } else {
            var copy = fileIds
            copy.removeLast()
            while copy.last != parentFileId {
                copy.removeLast()
            }
            copy.append(fileId)
            fileIds = copy
        }
    }
}

And wrap the container a GeometryReader and using the SaveToPageViewModel to follow the recursive view's length:

@State var itemWidth: CGFloat = 0
...
GeometryReader { proxy in
    ...
    RecursiveFolderListView(fileId: "root", selectedItem: $selectedItem, parentFileId: nil, itemWidth: itemWidth)
                    .environmentObject(viewModel)
    ...
}
.onReceive(viewModel.$fileIds) { fileIds in
    itemWidth = proxy.size.width / CGFloat(fileIds.count)
}

And in the RecursiveFolderListView, change the model data:

 RecursiveFolderListView(fileId: item.fileId, selectedItem: $selectedItem, parentFileId: fileId, itemWidth: itemWidth)
                    .id(theId)
...
}
.onAppear {
    model.tryInsert(fileId: fileId, parentFileId: parentFileId)
}

Upvotes: 0

rob mayoff
rob mayoff

Reputation: 385998

Since you didn't include definitions of ListItem or API in your post, here are some simple definitions:

struct ListItem: Hashable {
    let fileId: String
    var name: String
}

class API: ObservableObject {
    func getFiles(parentId: String) async throws -> [ListItem]? {
        return try FileManager.default
            .contentsOfDirectory(atPath: parentId)
            .sorted()
            .map { name in
                ListItem(
                    fileId: (parentId as NSString).appendingPathComponent(name),
                    name: name
                )
            }
    }
}

With those definitions (and changing the root fileId from "root" to "/"), we have a simple filesystem browser.

Now on to your question. Since you want each column to be the same width, you should put all the columns into a single HStack. Since you use recursion to visit the columns, you might think that's not possible, but I will demonstrate that it is possible. In fact, it requires just three simple changes:

  • Change VStack in MainView to HStack.

  • Change the outer HStack in RecursiveFolderListView to Group.

  • Move the .task modifier to the inner HStack around the "Empty" text, in the else branch.

The resulting code (with unchanged chunks omitted):

struct MainView: View {
    @State private var selectedItem: ListItem? = nil
    var body: some View {
        HStack { // ⬅️ changed
            RecursiveFolderListView(fileId: "/", selectedItem: $selectedItem)
        }
    }
}

struct RecursiveFolderListView: View {
    ...
    var body: some View {
        Group { // ⬅️ changed
            if let list = list, list.count > 0 {
                ...
            } else {
                HStack {
                    Spacer()
                    Text("Empty")
                    Spacer()
                }
                .task { // ⬅️ moved to here
                    list = try? await api.getFiles(parentId: fileId)
                }
            }
        }
        // ⬅️ .task moved from here
    }
}

I don't have the tvOS SDK installed, so I tested by commenting out the use of .focusSection() and running in an iPhone simulator:

demo of simple file browser drilling down through several folders

This works because the subviews of a Group are “flattened” into the Group's parent container. So when SwiftUI sees a hierarchy like this:

  • HStack
    • Group
      • ScrollView (first column)
      • Group
        • ScrollView (second column)
        • Group
          • ScrollView (third column)
          • HStack (fourth column, "Empty")

SwiftUI flattens it into this:

  • HStack
    • ScrollView (first column)
    • ScrollView (second column)
    • ScrollView (third column)
    • HStack (fourth column, "Empty")

I moved the .task modifier because otherwise it would be attached to the Group, which would pass it on to all of its child views, but we only need the task applied to one child view.

Upvotes: 1

Related Questions