How to Implement Dynamic SwiftUI View Hierarchy? - ios

I'm trying to implement a markdown renderer using SwiftUI. Markdown document contains a variety of blocks, where a block may be embedded in another block, for example, block quotes:
Quote Level 1
Quote Level 2
Quote Level 3
...
The entire document forms a tree-like structure with arbitrary depth, requiring the renderer to take a recursive approach. I adopted the following code structure:
#ViewBuilder
func renderBlock(block: Block) -> some View {
switch block {
// other types of elements
case let block as BlockQuote:
HStack {
GrayRectangle()
ForEach(block.children) { child in
renderBlock(child) // recursion
}
}
}
}
However, the compiler rejects that as it require the return type to be determined during compile phase. Is it possible to generate dynamic view structure like this in SwiftUI?

ViewHierarchy.swift:
import SwiftUI
#main
struct ViewHierarchyApp: App {
var body: some Scene {
WindowGroup {
ContentView(tree: mockData())
}
}
}
func mockData() -> [Tree] {
let tree2: [Tree] = [Tree(id: 2, title: "2", items: [])]
let tree1: [Tree] = [Tree(id: 1, title: "1", items: []),
Tree(id: 11, title: "11", items: []),
Tree(id: 12, title: "12", items: tree2)]
let tree: [Tree] = [Tree(id: 0, title: "Root", items: tree1)]
return tree
}
Model.swift:
class Tree: Identifiable {
let id: Int
let title: String
let items: [Tree]
init(id: Int, title: String, items: [Tree]) {
self.id = id
self.title = title
self.items = items
}
}
ContentView.swift:
struct ContentView: View {
let tree: [Tree]
var body: some View {
VStack {
ForEach(tree, id: \.id) { treeItem in
TreeViewItem(item: treeItem) {
VStack {
Text("-")
}
}
}
}
}
}
struct TreeViewItem<Content: View>: View {
let item: Tree
let content: Content
init(item: Tree, #ViewBuilder content: () -> Content) {
self.item = item
self.content = content()
}
var body: some View {
VStack {
Text(item.title)
ForEach(item.items, id: \.id) { treeItem in
TreeViewItem(item: treeItem) {
VStack {
Text("-")
} as! Content
}
}
}
content
}
}
Output:

Related

How to loop HashMap style in the View in SWIFTUI

var someProtocol = [SurveyItems : [Surveys]]()
sectionLabels.forEach{ a in
var finalSurveys = [Surveys]()
surveys.forEach{ b in
if a.groupHeader == b.group_survey {
finalSurveys.append(b)
}
someProtocol[a] = finalSurveys
}
}
I wanted to use that someProtocol to dynamically display the label section and the surveys under that section.
for (Surveys, SurveyItems) in someProtocol {
Text(Surveys.sectionTitle)
for survey in SurveyItems {
Text(survey.label)
}
}
I tried ViewBuider but getting some error.
To loop and display your someProtocol dictionary in a View, try this example code:
Adjust the code for your own purpose. Note that in a SwiftUI View you need to use a ForEach not the "normal" swift for x in ... to loop over a sequence.
struct ContentView: View {
#State var someProtocol = [SurveyItems : [Surveys]]()
var body: some View {
List(Array(someProtocol.keys), id: \.self) { key in
VStack {
if let surveys = someProtocol[key] {
Text(key.title).foregroundColor(.red)
ForEach(surveys, id: \.self) { survey in
Text("survey \(survey.label)")
}
}
}
}
.onAppear {
// for testing
someProtocol[SurveyItems(id: "1", number: 1, title: "title-1")] = [Surveys(id: "s1", label: "label-1"), Surveys(id: "s2", label: "label-2")]
someProtocol[SurveyItems(id: "2", number: 2, title: "title-2")] = [Surveys(id: "s3", label: "label-3")]
}
}
}
struct SurveyItems: Identifiable, Hashable {
let id: String
let number: Int
var title: String
}
struct Surveys: Identifiable, Hashable {
let id: String
let label: String
}

SwiftUI - Automatically add dividers between each element of a `ForEach`

I'm using a ForEach to display the contents of an array, then manually showing a divider between each element by checking the element index. Here's my code:
struct ContentView: View {
let animals = ["Apple", "Bear", "Cat", "Dog", "Elephant"]
var body: some View {
VStack {
/// array of tuples containing each element's index and the element itself
let enumerated = Array(zip(animals.indices, animals))
ForEach(enumerated, id: \.1) { index, animal in
Text(animal)
/// add a divider if the element isn't the last
if index != enumerated.count - 1 {
Divider()
.background(.blue)
}
}
}
}
}
Result:
This works, but I'd like a way to automatically add dividers everywhere without writing the Array(zip(animals.indices, animals)) every time. Here's what I have so far:
struct ForEachDividerView<Data, Content>: View where Data: RandomAccessCollection, Data.Element: Hashable, Content: View {
var data: Data
var content: (Data.Element) -> Content
var body: some View {
let enumerated = Array(zip(data.indices, data))
ForEach(enumerated, id: \.1) { index, data in
/// generate the view
content(data)
/// add a divider if the element isn't the last
if let index = index as? Int, index != enumerated.count - 1 {
Divider()
.background(.blue)
}
}
}
}
/// usage
ForEachDividerView(data: animals) { animal in
Text(animal)
}
This works great, isolating all the boilerplate zip code and still getting the same result. However, this is only because animals is an array of Strings, which conform to Hashable — if the elements in my array didn't conform to Hashable, it wouldn't work:
struct Person {
var name: String
}
struct ContentView: View {
let people: [Person] = [
.init(name: "Anna"),
.init(name: "Bob"),
.init(name: "Chris")
]
var body: some View {
VStack {
/// Error! Generic struct 'ForEachDividerView' requires that 'Person' conform to 'Hashable'
ForEachDividerView(data: people) { person in
Text(person.name)
}
}
}
}
That's why SwiftUI's ForEach comes with an additional initializer, init(_:id:content:), that takes in a custom key path for extracting the ID. I'd like to take advantage of this initializer in my ForEachDividerView, but I can't figure it out. Here's what I tried:
struct ForEachDividerView<Data, Content, ID>: View where Data: RandomAccessCollection, ID: Hashable, Content: View {
var data: Data
var id: KeyPath<Data.Element, ID>
var content: (Data.Element) -> Content
var body: some View {
let enumerated = Array(zip(data.indices, data))
/// Error! Invalid component of Swift key path
ForEach(enumerated, id: \.1.appending(path: id)) { index, data in
content(data)
if let index = index as? Int, index != enumerated.count - 1 {
Divider()
.background(.blue)
}
}
}
}
/// at least this part works...
ForEachDividerView(data: people, id: \.name) { person in
Text(person.name)
}
I tried using appending(path:) to combine the first key path (which extracts the element from enumerated) with the second key path (which gets the Hashable property from the element), but I got Invalid component of Swift key path.
How can I automatically add a divider in between the elements of a ForEach, even when the element doesn't conform to Hashable?
Simple way
struct ContentView: View {
let animals = ["Apple", "Bear", "Cat", "Dog", "Elephant"]
var body: some View {
VStack {
ForEach(animals, id: \.self) { animal in
Text(animal)
if animals.last != animal {
Divider()
.background(.blue)
}
}
}
}
}
Typically the type inside animals must be Identifiable. In which case the code will be modified as.
if animals.last.id != animal.id {...}
This will avoid any equatable requirements/ implementations
Does everything need to be in a ForEach? If not, you can consider not using indices at all:
struct ForEachDividerView<Data, Content, ID>: View where Data: RandomAccessCollection, ID: Hashable, Content: View {
var data: Data
var id: KeyPath<Data.Element, ID>
var content: (Data.Element) -> Content
var body: some View {
if let first = data.first {
content(first)
ForEach(data.dropFirst(), id: id) { element in
Divider()
.background(.blue)
content(element)
}
}
}
}
Found a solution!
appending(path:) seems to only work on key paths erased to AnyKeyPath.
Then, appending(path:) returns an optional AnyKeyPath? — this needs to get cast down to KeyPath<(Data.Index, Data.Element), ID> to satisfy the id parameter.
struct ForEachDividerView<Data, Content, ID>: View where Data: RandomAccessCollection, ID: Hashable, Content: View {
var data: Data
var id: KeyPath<Data.Element, ID>
var content: (Data.Element) -> Content
var body: some View {
let enumerated = Array(zip(data.indices, data))
/// first create a `AnyKeyPath` that extracts the element from `enumerated`
let elementKeyPath: AnyKeyPath = \(Data.Index, Data.Element).1
/// then, append the `id` key path to `elementKeyPath` to extract the `Hashable` property
if let fullKeyPath = elementKeyPath.appending(path: id) as? KeyPath<(Data.Index, Data.Element), ID> {
ForEach(enumerated, id: fullKeyPath) { index, data in
content(data)
if let index = index as? Int, index != enumerated.count - 1 {
Divider()
.background(.blue)
}
}
}
}
}
Usage:
struct Person {
var name: String
}
struct ContentView: View {
let people: [Person] = [
.init(name: "Anna"),
.init(name: "Bob"),
.init(name: "Chris")
]
var body: some View {
VStack {
ForEachDividerView(data: people, id: \.name) { person in
Text(person.name)
}
}
}
}
Result:

SwiftUI DisclosureGroup Expand each section individually

I'm using a Foreach and a DisclosureGroup to show data.
Each section can Expand/Collapse.
However they all are Expanding/Collapsing at the same time.
How do I Expand/Collapse each section individually?
struct TasksTabView: View {
#State private var expanded: Bool = false
var body: some View {
ForEach(Array(self.dict!.keys.sorted()), id: \.self) { key in
if let tasks = self.dict![key] {
DisclosureGroup(isExpanded: $expanded) {
ForEach(Array(tasks.enumerated()), id:\.1.title) { (index, task) in
VStack(alignment: .leading, spacing: 40) {
PillForRow(index: index, task: task)
.padding(.bottom, 40)
}.onTapGesture {
self.selectedTask = task
}
}
} label: {
Header(title: key, SubtitleText: Text(""), showTag: true, tagValue: tasks.count)
}.accentColor(.rhinoRed)
}
}
}
}
You could have a Set containing the keys of all the expanded sections. If a section is expanded, add it to the set. It is then removed when it is collapsed.
Code:
#State private var expanded: Set<String> = []
DisclosureGroup(
isExpanded: Binding<Bool>(
get: { expanded.contains(key) },
set: { isExpanding in
if isExpanding {
expanded.insert(key)
} else {
expanded.remove(key)
}
}
)
) {
/* ... */
}
It’s not working because the expanded flag links the DiscolureGroup all together. DisclosureGroup is smart enough to expand/collapse each item individually (see below demo).
struct ContentView: View {
struct Task: Identifiable, Hashable {
let id: UUID = UUID()
let name: String = "Task"
}
let allTasks: [[Task]] = [
[Task(), Task()],
[Task()],
[Task(), Task(), Task()]
]
var body: some View {
VStack {
ForEach(allTasks.indices, id: \.self) { indice in
DisclosureGroup() {
ForEach(allTasks[indice]) { task in
Text(task.name)
}
} label: {
Text("Tasks \(indice)")
}
}
}
}
}
However it seems that OutlineGroup is a perfect fit to your use case:
struct Task<Value: Hashable>: Hashable {
let value: Value
var subTasks: [Task]? = nil
}
List(allTasks, id: \.value, children: \.subTasks) { tree in
Text(tree.value).font(.subheadline)
}.listStyle(SidebarListStyle())

SwiftUI: How to update passing array item in the other view

I'm trying to update arrays item with typed new value into Textfield, but List is not updated with edited value.
My Code is:
Model:
struct WalletItem: Identifiable{
let id = UUID()
var name:String
var cardNumber:String
var type:String
var cvc:String
let pin:String
var dateOfExpiry:String
}
ModelView:
class Wallet: ObservableObject{
#Published var wallets = [
WalletItem(name: "BSB", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2016-06-29"),
WalletItem(name: "Alpha bank", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2017-03-12"),
WalletItem(name: "MTБ", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2020-11-12"),
]
}
First View:
struct WalletListView: View {
// Properties
// ==========
#ObservedObject var wallet = Wallet()
#State var isNewItemSheetIsVisible = false
var body: some View {
NavigationView {
List(wallet.wallets) { walletItem in
NavigationLink(destination: EditWalletItem(walletItem: walletItem)){
Text(walletItem.name)
}
}
.navigationBarTitle("Cards", displayMode: .inline)
.navigationBarItems(
leading: Button(action: { self.isNewItemSheetIsVisible = true
}) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add item")
}
}
)
}
.sheet(isPresented: $isNewItemSheetIsVisible) {
NewWalletItem(wallet: self.wallet)
}
}
}
and Secondary View:
struct EditWalletItem: View {
#State var walletItem: WalletItem
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form{
Section(header: Text("Card Name")){
TextField("", text: $walletItem.name)
}
}
.navigationBarItems(leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Back")
}, trailing:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Save")
})
}
}
P.S: If I use #Binding instead of the #State I've got an error in the first view: Initializer init(_:) requires that Binding<String> conform to StringProtocol
Here are modified parts (tested & works with Xcode 11.2 / iOS 13.2):
Sure over binding
struct EditWalletItem: View {
#Binding var walletItem: WalletItem
Place to pass it
List(Array(wallet.wallets.enumerated()), id: .element.id) { (i, walletItem) in
NavigationLink(destination: EditWalletItem(walletItem: self.$wallet.wallets[i])){
Text(walletItem.name)
}
}
ForEach(Array(list.enumerated())) will only work correctly if the list is an Array but not for an ArraySlice, and it has the downside of copying the list.
A better approach is using a .indexed() helper:
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { self.base.startIndex }
var endIndex: Index { self.base.endIndex }
func index(after i: Index) -> Index {
self.base.index(after: i)
}
func index(before i: Index) -> Index {
self.base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
self.base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: self.base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
Example:
// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/
import Foundation
import SwiftUI
struct Position {
var id = UUID()
var count: Int
var name: String
}
class BookingModel: ObservableObject {
#Published var positions: [Position]
init(positions: [Position] = []) {
self.positions = positions
}
}
struct EditableListExample: View {
#ObservedObject var bookingModel = BookingModel(
positions: [
Position(count: 1, name: "Candy"),
Position(count: 0, name: "Bread"),
]
)
var body: some View {
// >>> Passing a binding into an Array via index:
List(bookingModel.positions.indexed(), id: \.element.id) { i, _ in
PositionRowView(position: self.$bookingModel.positions[i])
}
}
}
struct PositionRowView: View {
#Binding var position: Position
var body: some View {
Stepper(
value: $position.count,
label: {
Text("\(position.count)x \(position.name)")
}
)
}
}
struct EditableListExample_Previews: PreviewProvider {
static var previews: some View {
EditableListExample()
}
}
See also:
How does the Apple-suggested .indexed() property work in a ForEach?

List reload animation glitches

So I have a list that changes when user fill in search keyword, and when there is no result, all the cells collapse and somehow they would fly over to the first section which looks ugly. Is there an error in my code or is this an expected SwiftUI behavior? Thanks.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel(photoLibraryService: PhotoLibraryService.shared)
var body: some View {
NavigationView {
List {
Section {
TextField("Enter Album Name", text: $viewModel.searchText)
}
Section {
if viewModel.libraryAlbums.count > 0 {
ForEach(viewModel.libraryAlbums) { libraryAlbum -> Text in
let title = libraryAlbum.assetCollection.localizedTitle ?? "Album"
return Text(title)
}
}
}
}.listStyle(GroupedListStyle())
.navigationBarTitle(
Text("Albums")
).navigationBarItems(trailing: Button("Add Album", action: {
PhotoLibraryService.shared.createAlbum(withTitle: "New Album \(Int.random(in: 1...100))")
}))
}.animation(.default)
}
}
1) you have to use some debouncing to reduce the needs to refresh the list, while typing in the search field
2) disable animation of rows
The second is the hardest part. the trick is to force recreate some View by setting its id.
Here is code of simple app (to be able to test this ideas)
import SwiftUI
import Combine
class Model: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
#Published var data = ["art", "audience", "association", "attitude", "ambition", "assistance", "awareness", "apartment", "artisan", "airport", "atmosphere", "actor", "army", "attention", "agreement", "application", "agency", "article", "affair", "apple", "argument", "analysis", "appearance", "assumption", "arrival", "assistant", "addition", "accident", "appointment", "advice", "ability", "alcohol", "anxiety", "ad", "activity"].map(DataRow.init)
var filtered: [DataRow] {
data.filter { (row) -> Bool in
row.txt.lowercased().hasPrefix(debouncedText.lowercased())
}
}
var id: UUID {
UUID()
}
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct DataRow: Identifiable {
let id = UUID()
let txt: String
init(_ txt: String) {
self.txt = txt
}
}
struct ContentView: View {
#ObservedObject var search = Model(delay: 0.5)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
TextField("filter", text: $search.text)
.padding(.vertical)
.padding(.horizontal)
List(search.filtered) { (e) in
Text(e.txt)
}.id(search.id)
}.navigationBarTitle("Navigation")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and i am happy with the result

Resources