SwiftUI picker separate texts for selected item and selection view - swift

I have a Picker embedded in a Form inside a NavigationView. I'd like to have a separate text for the chosen item in the main View and a more detailed descriptions when choosing items in the picker View.
This is what I tried so far:
struct Item {
let abbr: String
let desc: String
}
struct ContentView: View {
#State private var selectedIndex = 0
let items: [Item] = [
Item(abbr: "AA", desc: "aaaaa"),
Item(abbr: "BB", desc: "bbbbb"),
Item(abbr: "CC", desc: "ccccc"),
]
var body: some View {
NavigationView {
Form {
picker
}
}
}
var picker: some View {
Picker(selection: $selectedIndex, label: Text("Chosen item")) {
ForEach(0..<items.count) { index in
Group {
if self.selectedIndex == index {
Text(self.items[index].abbr)
} else {
Text(self.items[index].desc)
}
}
.tag(index)
}
.id(UUID())
}
}
}
Current solution
This is the picker in the main view:
And this is the selection view:
The problem is that with this solution in the selection view there is "BB" instead of "bbbbb".
This occurs because the "BB" text in both screens is produced by the very same Text view.
Expected result
The picker in the main view:
And in the selection view:
Is it possible in SwiftUI to have separate texts (views) for both screens?

Possible solution without a Picker
As mention in my comment, there is not yet a solution for a native implementation with the SwiftUI Picker. Instead, you can do it with SwiftUI Elements especially with a NavigationLink. Here is a sample code:
struct Item {
let abbr: String
let desc: String
}
struct ContentView: View {
#State private var selectedIndex = 0
let items: [Item] = [
Item(abbr: "AA", desc: "aaaaa"),
Item(abbr: "BB", desc: "bbbbb"),
Item(abbr: "CC", desc: "ccccc"),
]
var body: some View {
NavigationView {
Form {
NavigationLink(destination: (
DetailSelectionView(items: items, selectedItem: $selectedIndex)
), label: {
HStack {
Text("Chosen item")
Spacer()
Text(self.items[selectedIndex].abbr).foregroundColor(Color.gray)
}
})
}
}
}
}
struct DetailSelectionView: View {
var items: [Item]
#Binding var selectedItem: Int
var body: some View {
Form {
ForEach(0..<items.count) { index in
HStack {
Text(self.items[index].desc)
Spacer()
if self.selectedItem == index {
Image(systemName: "checkmark").foregroundColor(Color.blue)
}
}
.onTapGesture {
self.selectedItem = index
}
}
}
}
}
If there are any improvements feel free to edit the code snippet.

Expanding on JonasDeichelmann's answer I created my own picker:
struct CustomPicker<Item>: View where Item: Hashable {
#State var isLinkActive = false
#Binding var selection: Int
let title: String
let items: [Item]
let shortText: KeyPath<Item, String>
let longText: KeyPath<Item, String>
var body: some View {
NavigationLink(destination: selectionView, isActive: $isLinkActive, label: {
HStack {
Text(title)
Spacer()
Text(items[selection][keyPath: shortText])
.foregroundColor(Color.gray)
}
})
}
var selectionView: some View {
Form {
ForEach(0 ..< items.count) { index in
Button(action: {
self.selection = index
self.isLinkActive = false
}) {
HStack {
Text(self.items[index][keyPath: self.longText])
Spacer()
if self.selection == index {
Image(systemName: "checkmark")
.foregroundColor(Color.blue)
}
}
.contentShape(Rectangle())
.foregroundColor(.primary)
}
}
}
}
}
Then we have to make Item conform to Hashable:
struct Item: Hashable { ... }
And we can use it like this:
struct ContentView: View {
#State private var selectedIndex = 0
let items: [Item] = [
Item(abbr: "AA", desc: "aaaaa"),
Item(abbr: "BB", desc: "bbbbb"),
Item(abbr: "CC", desc: "ccccc"),
]
var body: some View {
NavigationView {
Form {
CustomPicker(selection: $selectedIndex, title: "Item", items: items,
shortText: \Item.abbr, longText: \Item.desc)
}
}
}
}
Note: Currently the picker's layout cannot be changed. If needed it can be made more generic using eg. #ViewBuilder.

I've had another try at a custom split picker.
Implementation
First, we need a struct as we'll use different items for selection, main screen and picker screen.
public struct PickerItem<
Selection: Hashable & LosslessStringConvertible,
Short: Hashable & LosslessStringConvertible,
Long: Hashable & LosslessStringConvertible
>: Hashable {
public let selection: Selection
public let short: Short
public let long: Long
public init(selection: Selection, short: Short, long: Long) {
self.selection = selection
self.short = short
self.long = long
}
}
Then, we create a custom view with an inner NavigationLink to simulate the behaviour of a Picker:
public struct SplitPicker<
Label: View,
Selection: Hashable & LosslessStringConvertible,
ShortValue: Hashable & LosslessStringConvertible,
LongValue: Hashable & LosslessStringConvertible
>: View {
public typealias Item = PickerItem<Selection, ShortValue, LongValue>
#State private var isLinkActive = false
#Binding private var selection: Selection
private let items: [Item]
private var showMultiLabels: Bool
private let label: () -> Label
public init(
selection: Binding<Selection>,
items: [Item],
showMultiLabels: Bool = false,
label: #escaping () -> Label
) {
self._selection = selection
self.items = items
self.showMultiLabels = showMultiLabels
self.label = label
}
public var body: some View {
NavigationLink(destination: selectionView, isActive: $isLinkActive) {
HStack {
label()
Spacer()
if let selectedItem = selectedItem {
Text(String(selectedItem.short))
.foregroundColor(Color.secondary)
}
}
}
}
}
private extension SplitPicker {
var selectedItem: Item? {
items.first { selection == $0.selection }
}
}
private extension SplitPicker {
var selectionView: some View {
Form {
ForEach(items, id: \.self) { item in
itemView(item: item)
}
}
}
}
private extension SplitPicker {
func itemView(item: Item) -> some View {
Button(action: {
selection = item.selection
isLinkActive = false
}) {
HStack {
if showMultiLabels {
itemMultiLabelView(item: item)
} else {
itemLabelView(item: item)
}
Spacer()
if item == selectedItem {
Image(systemName: "checkmark")
.font(Font.body.weight(.semibold))
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
}
}
}
private extension SplitPicker {
func itemLabelView(item: Item) -> some View {
HStack {
Text(String(item.long))
.foregroundColor(.primary)
Spacer()
}
}
}
private extension SplitPicker {
func itemMultiLabelView(item: Item) -> some View {
HStack {
HStack {
Text(String(item.short))
.foregroundColor(.primary)
Spacer()
}
.frame(maxWidth: 50)
Text(String(item.long))
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
Demo
struct ContentView: View {
#State private var selection = 2
let items = (1...5)
.map {
PickerItem(
selection: $0,
short: String($0),
long: "Long text of: \($0)"
)
}
var body: some View {
NavigationView {
Form {
Text("Selected index: \(selection)")
SplitPicker(selection: $selection, items: items) {
Text("Split picker")
}
}
}
}
}

Related

Cannot select item in List

I am trying to build a (macOS) view with a sidebar and a main area. The main area contains various custom views rather than a single view with a detail ID. My code looks like this:
enum SidebarTargetID: Int, Identifiable, CaseIterable {
case status, campaigns
var id: Int {
rawValue
}
}
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
var body: some View {
List(selection: $selection) {
Label("Status", systemImage: "heart").id(SidebarTargetID.status)
Label("Campaigns", systemImage: "heart").id(SidebarTargetID.campaigns)
}
}
}
struct ContentView: View {
#SceneStorage("sidebarSelection") private var selectedID: SidebarTargetID?
var body: some View {
NavigationView {
SidebarView(selection: selection)
switch selectedID {
case .status: StatusView()
case .campaigns: CampaignView()
default: StatusView()
}
}
}
private var selection: Binding<SidebarTargetID?> {
Binding(get: { selectedID ?? SidebarTargetID.status }, set: { selectedID = $0 })
}
}
The sidebar view, however, does not appear to be responding to its items being selected by clicking on them: no seleciton outline, no change of view in the main area.
Why is this? I have seen a ForEach being used in a List of Identifiable objects whose IDs activate the selection binding and do the selection stuff. What am I doing wrong?
EDIT
Tried this too, doesn't work.
enum SidebarTargetID: Int, Identifiable, CaseIterable {
case status, campaigns
var id: Int {
rawValue
}
}
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
let sidebarItems: [SidebarTargetID] = [
.status, .campaigns
]
var body: some View {
List(selection: $selection) {
ForEach(sidebarItems) { sidebarItem in
SidebarLabel(sidebarItem: sidebarItem)
}
}
}
}
struct SidebarLabel: View {
var sidebarItem: SidebarTargetID
var body: some View {
switch sidebarItem {
case .status: Label("Status", systemImage: "leaf")
case .campaigns: Label("Campaigns", systemImage: "leaf")
}
}
}
Just two little things:
the List items want a .tag not an .id:
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
var body: some View {
List(selection: $selection) {
Label("Status", systemImage: "heart")
.tag(SidebarTargetID.status) // replace .id with .tag
Label("Campaigns", systemImage: "heart")
.tag(SidebarTargetID.campaigns) // replace .id with .tag
}
}
}
you don't need the selection getter/setter. You can use #SceneStoragedirectly, it is a State var.
struct ContentView: View {
#SceneStorage("sidebarSelection") private var selectedID: SidebarTargetID?
var body: some View {
NavigationView {
SidebarView(selection: $selectedID) // replace selection with $selectedID
switch selectedID {
case .status: Text("StatusView()")
case .campaigns: Text("CampaignView()")
default: Text("StatusView()")
}
}
}
// no need for this
// private var selection: Binding<SidebarTargetID?> {
// Binding(get: { selectedID ?? SidebarTargetID.status }, set: { selectedID = $0 })
// }
}
Ok, found the problem:
struct SidebarView: View {
#Binding var selection: SidebarTargetID?
var sidebarItems: [SidebarTargetID] = [
.status, .campaigns
]
var body: some View {
List(sidebarItems, id: \.self, selection: $selection) { sidebarItem in
SidebarLabel(sidebarItem: sidebarItem)
}
}
}

How to create fully customizable sections with Binding text?

First of all, sorry for the title which is not precise at all, but I didn't know how else I could title this question. So:
1. What I want to achieve:
I want to create an interface where the user can add sections to input different fields, which are fully customizable by the user himself.
2. What I have done so far
I was able to create the interface, I can add new sections easily (structured with a "Section Name" on top and a TextField below) and they are customizable, but only in the TextField. They are also deletable, even though I had to do a complicated workaround since the Binding text of the TextField caused the app to crash because the index at which I was trying to remove the item resulted as "out of range". It's not the perfect workaround, but it works, and for now this is the most important thing. When I'll save these sections, I'll save them as an array of Dictionaries where every Dictionary has the section name and its value. However, there's still a few things I wasn't able to do:
3. What I haven't done yet
There are still 3 things that I couldn't do yet.
First of all, I'd like for the name of the section to be editable.
Secondly, I'd like to have the sections that the user adds displayed inside a Form and divided by Sections. As header, I'd like to have each different section name, and grouped inside all the related sections that share the same name.
Last but not least, I'd like to allow the user to add multiple TextFields to the same section. I have no idea how to handle this or even if it's possible.
4. Code:
ContentView:
import SwiftUI
struct ContentView: View {
#State var editSections = false
#State var arraySections : [SectionModel] = [SectionModel(name: "Prova", value: ""), SectionModel(name: "Prova 2", value: ""), SectionModel(name: "Prova", value: "")]
#State var showProgressView = false
#State var arraySectionsForDeleting = [SectionModel]()
#State var openSheetAdditionalSections = false
var body: some View {
Form {
if showProgressView == false {
if editSections == false {
ForEach(arraySections, id: \.id) { sect in
Section(header: Text(sect.name)) {
ForEach(arraySections, id: \.id) { sez in
if sez.name == sect.name {
SectionView(section: sez)
}
}
}
}
} else {
forEachViewSectionsForDeleting
}
if arraySections.count > 0 {
buttoneditSections
}
} else {
loadingView
}
Section {
addSections
}
}
.sheet(isPresented: $openSheetAdditionalSections, content: {
AdditionalSectionsSheet(closeSheet: $openSheetAdditionalSections, contView: self)
})
}
var forEachViewSectionsForDeleting : some View {
Section {
ForEach(arraySections, id: \.id) { sez in
HStack {
Text("\(sez.name) - \(sez.value)")
.foregroundColor(.black)
Spacer()
Button(action: {
showProgressView = true
let idx = arraySections.firstIndex(where: { $0.id == sez.id })
arraySectionsForDeleting.remove(at: idx!)
arraySections = []
arraySections = arraySectionsForDeleting
showProgressView = false
}, label: {
Image(systemName: "minus.circle")
.foregroundColor(.yellow)
}).buttonStyle(BorderlessButtonStyle())
}
}
}
}
var buttoneditSections : some View {
Button(action: {
editSections.toggle()
}, label: {
Text(editSections == true ? "Done" : "Edit Sections")
.foregroundColor(.yellow)
})
}
var forEachviewSezioniNonModifica : some View {
Section {
ForEach(arraySections, id: \.id) { sez in
Text(sez.name)
.foregroundColor(.black)
Text(sez.value)
.foregroundColor(.black)
}
}
}
var addSections : some View {
Button(action: {
openSheetAdditionalSections = true
}, label: {
HStack {
Text("Add sections")
.foregroundColor(.yellow)
Spacer()
Image(systemName: "plus.circle")
.foregroundColor(.yellow)
}
})
}
var loadingView : some View {
Section {
HStack {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .black))
Spacer()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
AddSectionSheet and SectionView:
import SwiftUI
struct AdditionalSectionsSheet: View {
#Binding var closeSheet : Bool
#Environment(\.colorScheme) var colorScheme
var contView : ContentView
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
#GestureState private var dragOffset = CGSize.zero
var body: some View {
NavigationView {
Form {
buttonPhone
buttonUrl
buttonEmail
buttonAddress
}
.navigationBarTitle(Text("Add section"), displayMode: .inline)
.navigationBarBackButtonHidden(true)
.navigationBarItems(trailing: Button(action : {
closeSheet = false
}){
Text("Close")
.foregroundColor(.yellow)
})
}
}
var buttonPhone : some View {
Button(action: {
contView.editSections = false
contView.arraySections.append(SectionModel(name: "Phone", value: ""))
contView.showProgressView = true
closeSheet = false
}, label: {
HStack {
Text("Phone")
.foregroundColor(.black)
Spacer()
}
})
}
var buttonUrl : some View {
Button(action: {
contView.editSections = false
contView.arraySections.append(SectionModel(name: "URL", value: ""))
closeSheet = false
}, label: {
HStack {
Text("URL")
.foregroundColor(.black)
Spacer()
}
})
}
var buttonAddress : some View {
Button(action: {
contView.editSections = false
contView.arraySections.append(SectionModel(name: "Address", value: ""))
contView.showProgressView = true
closeSheet = false
}, label: {
HStack {
Text("Address")
.foregroundColor(.black)
Spacer()
}
})
}
var buttonEmail : some View {
Button(action: {
contView.editSections = false
contView.arraySections.append(SectionModel(name: "Email", value: ""))
contView.showProgressView = true
closeSheet = false
}, label: {
HStack {
Text("Email")
.foregroundColor(.black)
Spacer()
}
})
}
}
struct SectionView : View {
#Environment(\.colorScheme) var colorScheme
#ObservedObject var section : SectionModel
var body : some View {
Section {
Text(section.name)
.foregroundColor(.black)
TextField(section.name, text: self.$section.value)
.foregroundColor(.black)
}
}
}
SectionModel:
import SwiftUI
import Combine
class SectionModel : Codable, Identifiable, Equatable, ObservableObject, Comparable {
var id = UUID()
var name : String
var value : String
init(name: String, value: String) {
self.name = name
self.value = value
}
static func == (lhs: SectionModel, rhs: SectionModel) -> Bool {
true
}
static func < (lhs: SectionModel, rhs: SectionModel) -> Bool {
true
}
}
I hope I wrote things clear enough to be understood, thanks to everyone who will help!

Why do I lose Data here?(SwiftUI)

I have two pages in my app TodayPage and CalendarList page.
I use EnvironmentObject wrapper to pass data between these two pages.
When TodayPage appears on onAppear modifier I call a function to generate days of calendar for me till now everything works fine when I add text to the list of TodayPage then go to the calendarList page and come back again to TodayPage all of the text that I addd to list are gone.I find out I can avoid lost of data by adding simple if to onAppear but I'm not sure this solution is right.
I have to upload lots of code ,Thanks for your help
( DataModel ) :
import SwiftUI
import Foundation
import Combine
struct Day : Identifiable {
var id = UUID()
var name : String
var date : String
var month : String
var List : [Text1?]
}
struct Text1 : Identifiable , Hashable{
var id = UUID()
var name: String
var color: Color
}
class AppState : ObservableObject {
#Published var dataLoaded = false
#Published var allDays : [Day] = [.init(name : "",date: "",month: "",List : [])]
func getDays(number: Int) -> [Day] {
let today = Date()
let formatter = DateFormatter()
return (0..<number).map { index -> Day in
let date = Calendar.current.date(byAdding: .day, value: index, to: today) ?? Date()
return Day(name: date.dayOfWeek(withFormatter: formatter) ?? "", date: "\(Calendar.current.component(.day, from: date))", month: date.nameOfMonth(withFormatter: formatter) ?? "", List: [])
}
}
}
extension Date {
func dayOfWeek(withFormatter dateFormatter: DateFormatter) -> String? {
dateFormatter.dateFormat = "EEEE"
return dateFormatter.string(from: self).capitalized
}
func nameOfMonth(withFormatter dateFormatter: DateFormatter) -> String? {
dateFormatter.dateFormat = "LLLL"
return dateFormatter.string(from: self).capitalized
}
}
class AddListViewViewModel : ObservableObject {
#Published var textItemsToAdd : [Text1] = [.init(name: "", color: .clear)] //start with one empty item
func saveToAppState(appState: AppState) {
appState.allDays[0].List.append(contentsOf: textItemsToAdd.filter {
!$0.name.isEmpty })
}
func bindingForId(id: UUID) -> Binding<String> {
.init { () -> String in
self.textItemsToAdd.first(where: { $0.id == id })?.name ?? ""
} set: { (newValue) in
self.textItemsToAdd = self.textItemsToAdd.map {
guard $0.id == id else {
return $0
}
return .init(id: id, name: newValue, color: .clear)
}
}
}
}
List view :
struct ListView: View {
#State private var showAddListView = false
#EnvironmentObject var appState : AppState
#Binding var dayList : [Text1?]
var title : String
var body: some View {
NavigationView {
VStack {
ZStack {
List(dayList, id : \.self){ text in
Text(text?.name ?? "")
}
if showAddListView {
AddListView(showAddListView: $showAddListView)
.offset(y:-100)
}
}
}
.navigationTitle(title)
.navigationBarItems(trailing:
Button(action: {showAddListView = true}) {
Image(systemName: "plus")
.font(.title2)
}
)
}
}
}
pop up menu View(for adding text into the list)
struct AddListView: View {
#Binding var showAddListView : Bool
#EnvironmentObject var appState : AppState
#StateObject private var viewModel = AddListViewViewModel()
var body: some View {
ZStack {
Title(addItem: { viewModel.textItemsToAdd.append(.init(name: "", color: .clear)) })
VStack {
ScrollView {
ForEach(viewModel.textItemsToAdd, id: \.id) { item in //note this is id: \.id and not \.self
PreAddTextField(textInTextField: viewModel.bindingForId(id: item.id))
}
}
}
.padding()
.offset(y: 40)
Buttons(showAddListView: $showAddListView, save: {
viewModel.saveToAppState(appState: appState)
})
}
.frame(width: 300, height: 200)
.background(Color.white)
.shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 10)
}
}
struct PreAddTextField: View {
#Binding var textInTextField : String
var body: some View {
VStack {
TextField("Enter text", text: $textInTextField)
}
}
}
struct Buttons: View {
#Binding var showAddListView : Bool
var save : () -> Void
var body: some View {
VStack {
HStack(spacing:100) {
Button(action: {
showAddListView = false}) {
Text("Cancel")
}
Button(action: {
showAddListView = false
save()
}) {
Text("Add")
}
}
}
.offset(y: 70)
}
}
struct Title: View {
var addItem : () -> Void
var body: some View {
VStack {
HStack {
Text("Add Text to list")
.font(.title2)
Spacer()
Button(action: {
addItem()
}) {
Image(systemName: "plus")
.font(.title2)
}
}
.padding()
Spacer()
}
}
}
TodayPage View :
struct TodayPage: View {
#EnvironmentObject var appState : AppState
var body: some View {
ListView(dayList: $appState.allDays[0].List, title: "Today")
.onAppear {
// To avoid data lost , we can use simple if below but I'm not sure it's a right solution
// if appState.dataLoaded == false {
appState.allDays = appState.getDays(number: 365)
// appState.dataLoaded = true
// }
}
}
}
CalendarListPage :
struct CalendarList: View {
#EnvironmentObject var appState : AppState
var body: some View {
NavigationView {
List {
ForEach(appState.allDays.indices, id:\.self) { index in
NavigationLink(destination: ListView(appState: _appState, dayList: $appState.allDays[index].List, title: appState.allDays[index].name).navigationBarTitleDisplayMode(.inline) ) {
HStack(alignment: .top) {
RoundedRectangle(cornerRadius: 23)
.frame(width: 74, height: 74)
.foregroundColor(Color.blue)
.overlay(
VStack {
Text(appState.allDays[index].date)
.font(.system(size: 35, weight: .regular))
.foregroundColor(.white)
Text(appState.allDays[index].month)
.foregroundColor(.white)
}
)
.padding(.trailing ,4)
VStack(alignment: .leading, spacing: 5) {
Text(appState.allDays[index].name)
.font(.system(size: 20, weight: .semibold))
}
}
.padding(.vertical ,6)
}
}
}
.navigationTitle("Calendar")
}.onAppear {
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
and finally TabBar :
struct TabBar: View {
var body: some View {
let appState = AppState()
TabView {
TodayPage().tabItem {
Image(systemName: "info.circle")
Text("Today")
}
CalendarList().tabItem {
Image(systemName: "square.fill.text.grid.1x2")
Text("Calendar")
}
}
.environmentObject(appState)
}
}
Right now, because your let appState is inside the body of TabBar, it gets recreated every time TabBar is rendered. Instead, store it as a #StateObject (or #ObservedObject if you are pre iOS 14):
struct TabBar: View {
#StateObject var appState = AppState()
var body: some View {
TabView {
TodayPage().tabItem {
Image(systemName: "info.circle")
Text("Today")
}
CalendarList().tabItem {
Image(systemName: "square.fill.text.grid.1x2")
Text("Calendar")
}
}
.onAppear {
appState.allDays = appState.getDays(number: 365)
}
.environmentObject(appState)
}
}
Then, remove your other onAppear on TodayPage

SwiftUI nested LazyVStacks in a single ScrollView

I'm trying to build a comment thread. So top level comments can all have nested comments and so can they and so on and so forth. But I'm having issues around scrolling and also sometimes when expanding sections the whole view just jumps around, and can have a giant blank space at the bottom. The code looks like this:
struct ContentView: View {
var body: some View {
VStack {
HStack {
Text("Comments")
.font(.system(size: 34))
.fontWeight(.bold)
Spacer()
}
.padding()
CommentListView(commentIds: [0, 1, 2, 3], nestingLevel: 1)
}
}
}
struct CommentListView: View {
let commentIds: [Int]?
let nestingLevel: Int
var body: some View {
if let commentIds = commentIds {
LazyVStack(alignment: .leading) {
ForEach(commentIds, id: \.self) { id in
CommentItemView(viewModel: CommentItemViewModel(commentId: id), nestingLevel: nestingLevel)
}
}
.applyIf(nestingLevel == 1) {
$0.scrollable()
}
} else {
Spacer()
Text("No comments")
Spacer()
}
}
}
struct CommentItemView: View {
#StateObject var viewModel: CommentItemViewModel
let nestingLevel: Int
#State private var showComments = false
var body: some View {
VStack {
switch viewModel.viewState {
case .error:
Text("Error")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .loading:
Text("Loading")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .complete:
VStack {
Text(viewModel.text)
.padding(.bottom)
.padding(.leading, 20 * CGFloat(nestingLevel))
if let commentIds = viewModel.commentIds {
Button {
withAnimation {
showComments.toggle()
}
} label: {
Text(showComments ? "Hide comments" : "Show comments")
}
if showComments {
CommentListView(commentIds: commentIds, nestingLevel: nestingLevel + 1)
}
}
}
}
}
}
}
class CommentItemViewModel: ObservableObject {
#Published private(set) var text = ""
#Published private(set) var commentIds: [Int]? = [0, 1, 2, 3]
#Published private(set) var viewState: ViewState = .loading
private let commentId: Int
private var viewStateInternal: ViewState = .loading {
willSet {
withAnimation {
viewState = newValue
}
}
}
init(commentId: Int) {
self.commentId = commentId
fetchComment()
}
private func fetchComment() {
viewStateInternal = .complete
text = CommentValue.allCases[commentId].rawValue
}
}
Has anyone got a better way of doing this? I know List can now accept a KeyPath to child object and it can nest that way, but there's so limited design control over List that I didn't want to use it. Also, while this code is an example, the real code will have to load each comment from an API call, so List won't perform as well as LazyVStack in that regard.
Any help appreciated - including a complete overhaul of how to implement this sort of async loading nested view.

SwiftUI How to create LazyVStack with selection as List

I would like to have something like List(selection: ) in LazyVStack.
The problem is that I don't know how to manage the content to split in each element that it contains.
What I've tried to do:
public struct LazyVStackSelectionable<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {
let content: Content
var selection: Binding<Set<SelectionValue>>?
#Environment(\.editMode) var editMode
public init(selection: Binding<Set<SelectionValue>>?, #ViewBuilder content: () -> Content) {
self.content = content()
self.selection = selection
}
public var body: some View {
if self.editMode?.wrappedValue == EditMode.active {
HStack {
content //here I would like to have something like ForEach (content, id:\.self)
Button(action: {
//add the UUID to the list of selected item
}) {
Image(systemName: "checkmark.circle.fill")
//Image(systemName: selection?.wrappedValue.contains(<#T##member: Hashable##Hashable#>) ? "checkmark.circle.fill" : "circle")
}
}
}
else {
content
}
}
}
struct ListView: View {
#State private var editMode: EditMode = .inactive
#State private var selection = Set<UUID>()
#State private var allElements: [MyElement] = [MyElement(id: UUID(), text: "one"),
MyElement(id: UUID(), text: "two" ),
MyElement(id: UUID(), text: "tree" )
]
var body: some View {
NavigationView {
VStack {
Divider()
Text("LazyVStack")
.foregroundColor(.red)
LazyVStack {
ForEach(allElements, id: \.self) { element in //section data
Text(element.text)
}
}
Divider()
Text("LazyVStackSelectionable")
.foregroundColor(.red)
LazyVStackSelectionable(selection: $selection) {
ForEach(allElements, id: \.self) { element in //section data
Text(element.text)
}
}
Divider()
}
.environment(\.editMode, self.$editMode)
.navigationBarTitle(Text("LIST"), displayMode: .inline)
.navigationBarItems(//EDIT
trailing:
Group {
HStack (spacing: 15) {
self.editButton
self.delInfoButton
.contentShape(Rectangle())
}
}
)
}
}
//MARK: EDIT MODE
private func deleteItems() {
DispatchQueue.global(qos: .userInteractive).async {
Thread.current.name = #function
selection.forEach{ idToRemove in
if let index = allElements.firstIndex(where: { $0.id == idToRemove }) {
allElements.remove(at: index)
}
}
}
}
private var editButton: some View {
Button(action: {
self.editMode.toggle()
self.selection = Set<UUID>()
}) {
Text(self.editMode.title)
}
}
private var delInfoButton: some View {
if editMode == .inactive {
return Button(action: {}) {
Image(systemName: "square.and.arrow.up")
}
} else {
return Button(action: deleteItems) {
Image(systemName: "trash")
}
}
}
}
struct ListView_Previews: PreviewProvider {
static var previews: some View {
ListView()
}
}
edit = .inactive
edit = .active
UPDATE
with Asperi's solution, I lose the propriety of LazyVStack, all the rows are loaded also if not displayed (and is also not scrollable:
struct SampleRow: View {
let number: Int
var body: some View {
Text("Sel Row \(number)")
}
init(_ number: Int) {
print("Loading LazySampleRow row \(number)")
self.number = number
}
}
struct LazySampleRow: View {
let number: Int
var body: some View {
Text("LVS element \(number)")
}
init(_ number: Int) {
print("Loading LazyVStack row \(number)")
self.number = number
}
}
var aLotOfElements: [MyElement] {
var temp: [MyElement] = []
for i in 1..<200 {
temp.append(MyElement(id: UUID(), number: i))
}
return temp
}
struct ContentView: View {
#State private var editMode: EditMode = .inactive
#State private var selection = Set<UUID>()
#State private var allElements: [MyElement] = aLotOfElements//[MyElement(id: UUID(), number: 1)]
var body: some View {
NavigationView {
HStack {
VStack {
Text("LazyVStack")
.foregroundColor(.red)
ScrollView {
LazyVStack (alignment: .leading) {
ForEach(allElements, id: \.self) { element in //section data
LazySampleRow(element.number)
}
}
}
}
Divider()
VStack {
LazyVStack (alignment: .leading) {
Divider()
Text("LazyVStackSelectionable")
.foregroundColor(.red)
LazyVStackSelectionable(allElements, selection: $selection) { element in
SampleRow(element.number)
}
Divider()
}
}
}
.environment(\.editMode, self.$editMode)
.navigationBarTitle(Text("LIST"), displayMode: .inline)
.navigationBarItems(//EDIT
trailing:
Group {
HStack (spacing: 15) {
self.editButton
self.delInfoButton
.contentShape(Rectangle())
}
}
)
}
}
//MARK: EDIT MODE
private func deleteItems() {
DispatchQueue.global(qos: .userInteractive).async {
Thread.current.name = #function
selection.forEach{ idToRemove in
if let index = allElements.firstIndex(where: { $0.id == idToRemove }) {
allElements.remove(at: index)
}
}
}
}
private var editButton: some View {
Button(action: {
self.editMode.toggle()
self.selection = Set<UUID>()
}) {
Text(self.editMode.title)
}
}
private var delInfoButton: some View {
if editMode == .inactive {
return Button(action: {}) {
Image(systemName: "square.and.arrow.up")
}
} else {
return Button(action: deleteItems) {
Image(systemName: "trash")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension EditMode {
var title: String {
self == .active ? NSLocalizedString("done", comment: "") : NSLocalizedString("edit", comment: "")
}
mutating func toggle() {
self = self == .active ? .inactive : .active
}
}
You need to create custom handled containers for all variants of desired content types.
Below is a demo of possible direction on the example of following content support (by example of List)
LazyVStackSelectionable(allElements, selection: $selection) { element in
Text(element.text)
}
Demo prepared and tested with Xcode 12 / iOS 14 (it is used some SwiftUI 2.0 features so if needed SwiftUI 1.0 support some more tuning will be needed)
struct LazyVStackSelectionable<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {
#Environment(\.editMode) var editMode
private var selection: Binding<Set<SelectionValue>>?
private var content: () -> Content
private var editingView: AnyView?
init(selection: Binding<Set<SelectionValue>>?, #ViewBuilder content: #escaping () -> Content)
{
self.selection = selection
self.content = content
}
var body: some View {
Group {
if editingView != nil && self.editMode?.wrappedValue == .active {
editingView!
} else {
self.content()
}}
}
}
extension LazyVStackSelectionable {
init<Data, RowContent>(_ data: Data, selection: Binding<Set<SelectionValue>>?, #ViewBuilder rowContent: #escaping (Data.Element) -> RowContent) where Content == ForEach<Data, Data.Element.ID, HStack<RowContent>>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable, SelectionValue == Data.Element.ID
{
self.init(selection: selection, content: {
ForEach(data) { el in
HStack {
rowContent(el)
}
}
})
editingView = AnyView(
ForEach(data) { el in
HStack {
rowContent(el)
if let selection = selection {
Button(action: {
if selection.wrappedValue.contains(el.id) {
selection.wrappedValue.remove(el.id)
} else {
selection.wrappedValue.insert(el.id)
}
}) {
Image(systemName: selection.wrappedValue.contains(el.id) ? "checkmark.circle.fill" : "circle")
}
}
}
}
)
}
}
Instead of creating custom LazyVStack I suggest to modify ContentView and pass bindings to it.
struct SampleRow: View {
let element: MyElement
let editMode: Binding<EditMode>
let selection: Binding<Set<UUID>>?
var body: some View {
HStack {
if editMode.wrappedValue == .active,
let selection = selection {
Button(action: {
if selection.wrappedValue.contains(element.id) {
selection.wrappedValue.remove(element.id)
} else {
selection.wrappedValue.insert(element.id)
}
}) {
Image(systemName: selection.wrappedValue.contains(element.id) ? "checkmark.circle.fill" : "circle")
}
}
Text("Sel Row \(element.number)")
}
}
init(_ element: MyElement,
editMode: Binding<EditMode>,
selection: Binding<Set<UUID>>?) {
print("Loading LazySampleRow row \(element.number)")
self.editMode = editMode
self.element = element
self.selection = selection
}
}
And then you can just wrap normal LazyVStack in ScrollView to achieve what you need.
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(allElements, id: \.self) {
SampleRow($0,
editMode: $editMode,
selection: $selection)
}
}
}