Reputation: 867
I want to make a recursive view like this:
But what I have done is like this:
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
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
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:
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