We're using Hierarchical lists in SwiftUI. The List takes an optional array for the children argument children: \.children. We would like to however use a 'non-optional' array
Example from https://developer.apple.com/documentation/swiftui/list
struct ContentView: View {
struct FileItem: Hashable, Identifiable, CustomStringConvertible {
var id: Self { self }
var name: String
var children: [FileItem]? = nil
var description: String {
switch children {
case nil:
return "📄 \(name)"
case .some(let children):
return children.isEmpty ? "📂 \(name)" : "📁 \(name)"
}
}
}
let fileHierarchyData: [FileItem] = [
FileItem(name: "users", children:
[FileItem(name: "user1234", children:
[FileItem(name: "Photos", children:
[FileItem(name: "photo001.jpg"),
FileItem(name: "photo002.jpg")]),
FileItem(name: "Movies", children:
[FileItem(name: "movie001.mp4")]),
FileItem(name: "Documents", children: [])
]),
FileItem(name: "newuser", children:
[FileItem(name: "Documents", children: [])
])
]),
FileItem(name: "private", children: nil)
]
var body: some View {
List(fileHierarchyData, children: \.children) { item in
Text(item.description)
}
}
}
so instead of using
var children: [FileItem]? = nil
we'd like to use
var children: [FileItem] = []
This of course produces a compiler error
Key path value type '[FileItem]' cannot be converted to contextual type '[FileItem]?'
How can one cast from [FileItem] to [FileItem]?
A possible approach is force-unwrapped optional (to fulfil List contract) and initial value and guard setter, so in all places it can be used directly w/o ?. It is the same approach as is used for IBOutlet declarations.
Tested with Xcode 13.4 / iOS 15.5
struct ContentView: View {
struct FileItem: Hashable, Identifiable, CustomStringConvertible {
var id: Self { self }
var name: String
var children: [FileItem]! = [] { // with default !!
didSet {
if children == nil { // guard !!
children = []
}
}
}
var description: String {
children.isEmpty ? "📂 \(name)" : "📁 \(name)" // << direct use !!
}
}
let fileHierarchyData: [FileItem] = [
FileItem(name: "users", children:
[FileItem(name: "user1234", children:
[FileItem(name: "Photos", children:
[FileItem(name: "photo001.jpg"),
FileItem(name: "photo002.jpg")]),
FileItem(name: "Movies", children:
[FileItem(name: "movie001.mp4")]),
FileItem(name: "Documents")
]),
FileItem(name: "newuser", children:
[FileItem(name: "Documents")
])
]),
FileItem(name: "private")
]
var body: some View {
List(fileHierarchyData, children: \.children) { item in
Text(item.description)
}
}
}
What I got from your question is you want to use non-optional array you can update like this.
var children: [FileItem]! = [] {
didSet {
if children.isEmpty {
children = []
}
}
}
Related
I'm building a quiz app where the user should be able to select which categories they want to have, and then I'd like to filter the questions based on whether the category isActive.
I figure solving both would be too much for one post maybe, so I'm focusing on toggling the categories. I have tried creating a updateCategory function in my ViewModel but I can only tap on the first category. If I tap on any other category, only the first one gets updated (changes name and icon). Anyone that can point me in the right direction?
Here's my CategoryModel:
struct CategoryModel: Identifiable, Codable, Hashable {
var id: String
var icon: String
var name: String
var isActive: Bool
enum CodingKeys: String, CodingKey {
case id, icon, name, isActive
}
}
And here's my QuestionModel:
struct QuestionModel: Identifiable, Codable, Hashable {
var id = UUID().uuidString
var question: String
var category: String
var answer: Int
var options: [String]
enum CodingKeys: String, CodingKey {
case id, question, category, answer, options
}
// Match the answer index with the correct option
func theAnswer() -> String {
return (answer >= 0 && answer < options.count) ? options[answer] : ""
}
}
And here's a part of my GameModel:
struct Game {
// Get questions from JSON file
static var getQuestions: [QuestionModel] = Bundle.main.decode("questions.json")
// Shuffled questions
let questions = getQuestions.shuffled()
// Get categories
var categories: [CategoryModel] = [
CategoryModel(id: "", icon: "music.quarternote.3", name: "Musik", isActive: true),
CategoryModel(id: "", icon: "hourglass.bottomhalf.filled", name: "Historia", isActive: false),
CategoryModel(id: "", icon: "tv.fill", name: "Film & TV", isActive: true),
CategoryModel(id: "", icon: "pawprint.fill", name: "Natur & Vetenskap", isActive: true),
CategoryModel(id: "", icon: "globe.europe.africa.fill", name: "Geografi", isActive: true),
CategoryModel(id: "", icon: "sportscourt.fill", name: "Sport", isActive: true)
]
}
Here's part of my ViewModel:
class GameVM: ObservableObject {
#Published var game = Game()
//MARK: - Category logic
var categories: [CategoryModel] {
game.categories
}
func updateCategory(category: CategoryModel) {
if let index = categories.firstIndex(where: { $0.id == category.id }) {
game.categories[index] = category.updateCompletion()
}
}
var categoryIndices: Range<Int> {
game.categories.indices
}
}
Here's my CategoryListView where I display the categories:
struct CategoryListView: View {
#StateObject var viewModel = GameVM()
var columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 8), count: 2)
var body: some View {
VStack {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(viewModel.categories, id: \.self) { category in
CategoryCardView(icon: category.icon, name: category.name, isActive: category.isActive)
.frame(maxWidth: .infinity, minHeight: 120)
.background(
category.isActive == true ? Color(UIColor.systemGray6) : Color(UIColor.black)
)
.cornerRadius(24)
.onTapGesture {
withAnimation(.spring()) {
viewModel.updateCategory(category: category)
}
}
}
}
}
.padding()
.navigationTitle("Choose Categories")
.navigationBarTitleDisplayMode(.inline)
}
}
First part
You are initialising each category with the same id "",
var categories: [CategoryModel] = [
CategoryModel(id: "", icon: "music.quarternote.3", name: "Musik", isActive: true),
CategoryModel(id: "", icon: "hourglass.bottomhalf.filled", name: "Historia", isActive: false),
CategoryModel(id: "", icon: "tv.fill", name: "Film & TV", isActive: true),
CategoryModel(id: "", icon: "pawprint.fill", name: "Natur & Vetenskap", isActive: true),
CategoryModel(id: "", icon: "globe.europe.africa.fill", name: "Geografi", isActive: true),
CategoryModel(id: "", icon: "sportscourt.fill", name: "Sport", isActive: true)
]
causing this to return index 0 every time.
if let index = categories.firstIndex(where: { $0.id == category.id }) {
Best fix would be to initialise id in CategoryModel like you have in QuestionModel
struct CategoryModel: Identifiable, Codable, Hashable {
var id = UUID().uuidString
var icon: String
var name: String
var isActive: Bool
enum CodingKeys: String, CodingKey {
case id, icon, name, isActive
}
}
Second part
One way of achieving this is to create a filteredQuestions variable inside GameVM like this
var filteredQuestions: [QuestionModel] {
let filteredCategoryNames = categories
.filter(\.isActive) // filters for the active categories
.map(\.name) // turns it into an array containing the name of each category
return questions.filter { question in
filteredCategoryNames.contains(question.category)
}
}
Currently, .itemProvider is not working properly with OutlineGroup.
OutlineGroup in List: Not Work
Hierarchical List: Not Work
Pure List: Work
I think it is a bug or SwiftUI doesn't support it yet.
Is there any workaround? or should I use cocoa OutlineView for this feature?
Reproduce:
Copy and Paste code below, and drag items.
import SwiftUI
import PlaygroundSupport
var greeting = "Hello, playground"
struct FileItem: Hashable, Identifiable {
var id: Self { self }
var name: String
var children: [FileItem]? = nil
}
let data = FileItem(name: "users", children:
[FileItem(name: "user1234", children:
[FileItem(name: "Photos", children:
[FileItem(name: "photo001.jpg"),
FileItem(name: "photo002.jpg")]),
FileItem(name: "Movies", children:
[FileItem(name: "movie001.mp4")]),
FileItem(name: "Documents", children: [])]),
FileItem(name: "newuser", children:
[FileItem(name: "Documents", children: [])])])
struct ContentView: View {
var body: some View {
VStack {
Text("Not Work - OutlineGroup in List")
List {
OutlineGroup(data, children: \.children) { item in
Text("\(item.name)")
.itemProvider { NSItemProvider(object: item.name as NSString) }
}
}
Text("Not Work - Hierarchical List")
List {
OutlineGroup(data, children: \.children) { item in
Text("\(item.name)")
.itemProvider { NSItemProvider(object: item.name as NSString) }
}
}
Text("Work without item info")
List {
OutlineGroup(data, children: \.children) { item in
Text("\(item.name)")
}
.itemProvider { NSItemProvider() }
}
Text("Work - Pure List")
List([
FileItem(name: "Documents", children: []),
FileItem(name: "Files", children: [])
]) { item in
Text("\(item.name)")
.itemProvider { NSItemProvider(object: item.name as NSString) }
}
}
.frame(width: 320, height: 800)
}
}
PlaygroundPage.current.setLiveView(ContentView())
List(
[root],
children: \.children)
) { item in
}
It has the same structure as above.
I want to show files to the user as they are automatically opened by searching in the File Tree. Is there a way?
View
struct ContentView: View {
#StateObject var disk = Disk()
var body: some View {
List(disk.items, children: \.children) { item in
Label() {
Text(item.name)
} icon: {
Image(systemName: "folder")
}
}
}
}
Model
class Disk: ObservableObject {
#Published var items: [Item]
init() {
let item1 = Item(id: 1, name: "child 1", children: nil)
let item2 = Item(id: 2, name: "child 2", children: nil)
let item3 = Item(id: 3, name: "child 3", children: nil)
let item4 = Item(id: 4, name: "child 4", children: [item1, item2, item3])
let item5 = Item(id: 5, name: "child 5", children: nil)
let item6 = Item(id: 6, name: "child 6", children: nil)
let root = Item(id: 7, name: "Parent", children: [item4, item5, item6])
items = [root]
}
}
struct Item: Identifiable {
var id: Int
var name: String
var children: [Item]?
}
OutlineGroup is an analogue to NSOutlineView. NSOutlineView supports single/multiple node selection and we can obtain them by querying on NSOutlineView. Though obtaining selection on NSOutlineView is O(n), but this can be optimized to O(1) if the view tracks selection and provide them in proper interface.
How to obtain selections from OutlineGroup? Especially for multiple node selections. I checked out the manual entry, but couldn't find any mention about selection. What am I missing here?
Here is the code.
import SwiftUI
struct ContentView: View {
#State var selection = Set<FileItem.ID>()
var body: some View {
NavigationView {
VStack {
List(selection: $selection) {
OutlineGroup(data, children: \.children) { item in
Text("\(item.description)")
}
.onTapGesture {
print(selection)
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("Files")
//.toolbar { EditButton() }
.environment(\.editMode, .constant(.active))
.onTapGesture {
// Walkaround: try how it works without `asyncAfter()`
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
print(selection)
})
}
Text("\(selection.count) selections")
}
}
}
}
// Sample data:
struct FileItem: Hashable, Identifiable, CustomStringConvertible {
var id: Self { self }
var name: String
var header: String?
var children: [FileItem]? = nil
var description: String {
switch children {
case nil:
return "📄 \(name)"
case .some(let children):
return children.isEmpty ? "📂 \(name)" : "📁 \(name)"
}
}
}
let data =
FileItem(name: "users", children:
[FileItem(name: "user1234", children:
[FileItem(name: "Photos", header: "Header 1", children:
[FileItem(name: "photo001.jpg", header: "Header 2"),
FileItem(name: "photo002.jpg")]),
FileItem(name: "Movies", children:
[FileItem(name: "movie001.mp4")]),
FileItem(name: "Documents", children: [])
]),
FileItem(name: "newuser", children:
[FileItem(name: "Documents", children: [])
])
])
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Documentation is not completely finished as it looks. Use direct SwiftUI autogenerated interfaces in Xcode 12 to find updates.
Especially for asked OutlineGroup there are several constructors with selection parameter, like below:
/// Creates a hierarchical list that computes its rows on demand from an
/// underlying collection of identifiable data, optionally allowing users to
/// select multiple rows.
///
/// - Parameters:
/// - data: The identifiable data for computing the list.
/// - selection: A binding to a set that identifies selected rows.
/// - rowContent: A view builder that creates the view for a single row of
/// the list.
#available(iOS 14.0, OSX 10.16, *)
#available(tvOS, unavailable)
#available(watchOS, unavailable)
public init<Data, RowContent>(_ data: Data, children: KeyPath<Data.Element, Data?>,
selection: Binding<Set<SelectionValue>>?, #ViewBuilder rowContent: #escaping (Data.Element) -> RowContent) where Content == OutlineGroup<Data, Data.Element.ID, HStack<RowContent>, HStack<RowContent>, DisclosureGroup<HStack<RowContent>, OutlineSubgroupChildren>>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable
You need to put NavigationLink/Button for the item which do not have children.
Here is how it could look based on Apple source code.
var body: some View {
OutlineGroup(data, children: \.children) { item in
Group {
if item.children == nil {
NavigationLink(
destination: Text("\(item.name)"),
label: {
Text ("\(item.description)")
})
} else {
Text ("\(item.description)")
}
}
}
}
The data comes from an Apple example. Sometimes links gets broken. So, here is source code:
struct FileItem: Hashable, Identifiable, CustomStringConvertible {
var id: Self { self }
var name: String
var children: [FileItem]? = nil
var description: String {
switch (children) {
case nil:
return "📄 \(name)"
case .some(let children):
return children.count > 0 ? "📂 \(name)" : "📁 \(name)"
}
}
}
let data =
FileItem(name: "users", children:
[FileItem(name: "user1234", children:
[FileItem(name:"Photos", children:
[FileItem(name: "photo001.jpg"),
FileItem(name: "photo002.jpg")]),
FileItem(name:"Movies", children:
[FileItem(name: "movie001.mp4")]),
FileItem(name:"Documents", children: [])
]),
FileItem(name: "newuser", children:
[FileItem (name: "Documents", children: [])
])
])
Using a NavigationLink as the view to represent the children of the tree actually do work
I'm using SwiftUI to animate an expand and collapse in a list.
How can I get the height expansion of the section to animate smoothly like it would in UIKit with a tableview?
struct Rows: View {
let rows = ["Row 1", "Row 2", "Row 3", "Row 4", "Row 5"]
var body: some View {
Section {
ForEach(rows.identified(by: \.self)) { name in
Text(name)
.lineLimit(nil)
}
}
}
}
struct Header: View {
#State var isExpanded: Bool = false
var body: some View {
VStack(alignment: .leading) {
Button(action: {
self.isExpanded.toggle()
}) {
Text(self.isExpanded ? "Collapse Me" : "Expand Me")
.font(.footnote)
}
if self.isExpanded {
Rows().animation(.fluidSpring())
}
}
}
}
struct ContentView : View {
var body: some View {
List(0...4) { _ in
Header()
}
}
}
The animation seems to only apply to the text in the rows not the actual height or separator line growing to accommodate the new rows. The row text also seems to start animating from the very top of the row rather than where it appears in the view hierarchy. I need a smooth animation.
I implemented it like this: (It is with proper animation)
struct ExpandCollapseList : View {
#State var sectionState: [Int: Bool] = [:]
var body: some View {
NavigationView {
List {
ForEach(1 ... 6, id: \.self) { section in
Section(header: Text("Section \(section)").onTapGesture {
self.sectionState[section] = !self.isExpanded(section)
}) {
if self.isExpanded(section) {
ForEach(1 ... 4, id: \.self) { row in
Text("Row \(row)")
}
}
}
}
}
.navigationBarTitle(Text("Expand/Collapse List"))
.listStyle(GroupedListStyle())
}
}
func isExpanded(_ section: Int) -> Bool {
sectionState[section] ?? false
}
}
Thanks to Aakash Jaiswal's answer I was able to expand upon this implementation to suit my need to expand to three tiers, i.e., Section, Subsection, and Lesson. The compiler failed to compile the whole implementation in a single View, which is why I separated it out.
import SwiftUI
struct MenuView: View {
var body: some View {
HStack {
List {
ToggleableMenuItemsView(sections: menuItems)
.padding()
}
}
.background(Color("Gray"))
.cornerRadius(30)
.padding(.top, 30)
.padding(.trailing, bounds.width * 0.2)
.padding(.bottom, 30)
.shadow(radius: 10)
}
#State var menuItemState = [String: Bool]()
private var bounds: CGRect { UIScreen.main.bounds }
private func isExpanded(_ menuItem: MenuItem) -> Bool {
menuItemState[menuItem.id] ?? false
}
}
struct ToggleableMenuItemsView: View {
let sections: [MenuItem]
var body: some View {
ForEach(sections) { section in
Section(
header: Text(section.title)
.font(.title)
.onTapGesture { self.menuItemState[section.id] = !self.isExpanded(section) },
content: {
if self.isExpanded(section) {
ForEach(section.children) { subsection in
Section(
header: Text(subsection.title)
.font(.headline)
.onTapGesture { self.menuItemState[subsection.id] = !self.isExpanded(subsection) },
content: {
if self.isExpanded(subsection) {
LessonsListView(lessons: subsection.children)
}
}
)
}
}
}
)
}
}
#State var menuItemState = [String: Bool]()
private func isExpanded(_ menuItem: MenuItem) -> Bool {
menuItemState[menuItem.id] ?? false
}
}
struct LessonsListView: View {
let lessons: [MenuItem]
var body: some View {
ForEach(lessons) { lesson in
Text(lesson.title)
.font(.subheadline)
}
}
}
class MenuItem: Identifiable {
var id: String
let title: String
var children: [MenuItem]
init(id: String, title: String, children: [MenuItem] = []) {
self.id = id
self.title = title
self.children = children
}
}
let menuItems = [
MenuItem(
id: "01",
title: "The Land in its World",
children: [
MenuItem(
id: "01A",
title: "North and South",
children: [
MenuItem(
id: "01A01",
title: "Between Continents"
),
MenuItem(
id: "01A02",
title: "The Wet North"
),
MenuItem(
id: "01A03",
title: "The Dry South"
),
MenuItem(
id: "01A04",
title: "Between Wet and Dry"
)
]
),
MenuItem(
id: "01B",
title: "East and West",
children: [
MenuItem(
id: "01B01",
title: "Sea and Desert"
),
MenuItem(
id: "01B02",
title: "Exchange in Aram"
),
MenuItem(
id: "01B03",
title: "Exchange in Egypt"
),
MenuItem(
id: "01B04",
title: "A Bypass Between"
)
]
),
MenuItem(
id: "01C",
title: "Between Empires",
children: [
MenuItem(
id: "01C01",
title: "Imperial Dreams"
),
MenuItem(
id: "01C02",
title: "Egypt Marches"
),
MenuItem(
id: "01C03",
title: "Taking Egypt's Wealth"
),
MenuItem(
id: "01C04",
title: "The Land Between"
)
]
)
]
)
]
struct MenuView_Previews: PreviewProvider {
static var previews: some View {
MenuView()
}
}
Here's a demo
try to implement it like this:
struct ContentView : View {
#State var expanded:[Int:Bool] = [:]
func isExpanded(_ id:Int) -> Bool {
expanded[id] ?? false
}
var body: some View {
NavigationView{
List {
ForEach(0...80) { section in
Section(header: CustomeHeader(name: "Section \(section)", color: Color.white).tapAction {
self.expanded[section] = !self.isExpanded(section)
}) {
if self.isExpanded(section) {
ForEach(0...30) { row in
Text("Row \(row)")
}
}
}
}
}
}.navigationBarTitle(Text("Title"))
}
}
struct CustomeHeader: View {
let name: String
let color: Color
var body: some View {
VStack {
Spacer()
HStack {
Text(name)
Spacer()
}
Spacer()
Divider()
}
.padding(0)
.background(color.relativeWidth(1.3))
.frame(height: 50)
}
}