Conditionally Text in SwiftUI depending on Array value - if-statement

I want make placeholder custom style so i try to use the method of Mojtaba Hosseini in SwiftUI. How to change the placeholder color of the TextField?
if text.isEmpty {
Text("Placeholder")
.foregroundColor(.red)
}
but in my case, I use a foreach with a Array for make a list of Textfield and Display or not the Text for simulate the custom placeholder.
ForEach(self.ListeEquip.indices, id: \.self) { item in
ForEach(self.ListeJoueurs[item].indices, id: \.self){idx in
// if self.ListeJoueurs[O][O] work
if self.ListeJoueurs[item][index].isEmpty {
Text("Placeholder")
.foregroundColor(.red)
}
}
}
How I can use dynamic conditional with a foreach ?
Now I have a another problem :
i have this code :
struct EquipView: View {
#State var ListeJoueurs = [
["saoul", "Remi"],
["Paul", "Kevin"]
]
#State var ListeEquip:[String] = [
"Rocket", "sayans"
]
var body: some View {
VStack { // Added this
ForEach(self.ListeEquip.indices) { item in
BulleEquip(EquipName: item, ListeJoueurs: self.$ListeJoueurs, ListeEquip: self.$ListeEquip)
}
}
}
}
struct BulleEquip: View {
var EquipName = 0
#Binding var ListeJoueurs :[[String]]
#Binding var ListeEquip :[String]
var body: some View {
VStack{
VStack{
Text("Équipe \(EquipName+1)")
}
VStack { // Added this
ForEach(self.ListeJoueurs[EquipName].indices) { index in
ListeJoueurView(EquipNamed: self.EquipName, JoueurIndex: index, ListeJoueurs: self.$ListeJoueurs, ListeEquip: self.$ListeEquip)
}
HStack{
Button(action: {
self.ListeJoueurs[self.EquipName].append("") //problem here
}){
Text("button")
}
}
}
}
}
}
struct ListeJoueurView: View {
var EquipNamed = 0
var JoueurIndex = 0
#Binding var ListeJoueurs :[[String]]
#Binding var ListeEquip :[String]
var body: some View {
HStack{
Text("Joueur \(JoueurIndex+1)")
}
}
}
I can run the App but I have this error in console when I click the button :
ForEach, Int, ListeJoueurView> count (3) != its initial count (2). ForEach(_:content:) should only be used for constant data. Instead conform data to Identifiable or use ForEach(_:id:content:) and provide an explicit id!
Can someone enlighten me?

TL;DR
You need a VStack, HStack, List, etc outside each ForEach.
Updated
For the second part of your question, you need to change your ForEach to include the id parameter:
ForEach(self.ListeJoueurs[EquipName].indices, id: \.self)
If the data is not constant and the number of elements may change, you need to include the id: \.self so SwiftUI knows where to insert the new views.
Example
Here's some example code that demonstrates a working nested ForEach. I made up a data model that matches how you were trying to call it.
struct ContentView: View {
// You can ignore these, since you have your own data model
var ListeEquip: [Int] = Array(1...3)
var ListeJoueurs: [[String]] = []
// Just some random data strings, some of which are empty
init() {
ListeJoueurs = (1...4).map { _ in (1...4).map { _ in Bool.random() ? "Text" : "" } }
}
var body: some View {
VStack { // Added this
ForEach(self.ListeEquip.indices, id: \.self) { item in
VStack { // Added this
ForEach(self.ListeJoueurs[item].indices, id: \.self) { index in
if self.ListeJoueurs[item][index].isEmpty { // If string is blank
Text("Placeholder")
.foregroundColor(.red)
} else { // If string is not blank
Text(self.ListeJoueurs[item][index])
}
}
}.border(Color.black)
}
}
}
}
Explanation
Here's what Apple's documentation says about ForEach:
A structure that computes views on demand from an underlying collection of of [sic] identified data.
So something like
ForEach(0..2, id: \.self) { number in
Text(number.description)
}
is really just shorthand for
Text("0")
Text("1")
Text("2")
So your ForEach is making a bunch of views, but this syntax for declaring views is only valid inside a View like VStack, HStack, List, Group, etc. The technical reason is because these views have an init that looks like
init(..., #ViewBuilder content: () -> Content)
and that #ViewBuilder does some magic that allows this unique syntax.

Related

how to make the recursive view the same width?

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.
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.
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)
}

listRowBackground removes selection style

When using listRowBackground on a SwiftUI List there is no longer any highlighting of the selected item. Using a ButtonStyle for the NavigationLink does not work either.
Are there any sane workaround for this?
Example code:
struct ContentView: View {
struct ContentSection: Identifiable {
let id = UUID()
let title: String
let items: [String]
}
var sections = [
ContentSection(title: "Lorem", items: ["Dolor", "Sit", "Amed"]),
ContentSection(title: "Ipsum", items: ["Consectetur", "Adipiscing", "Elit"])
]
var body: some View {
NavigationView {
List {
ForEach(sections) { section in
Section {
ForEach(section.items, id: \.self) { item in
NavigationLink(destination: Text(item)) {
Text(item)
}
.listRowBackground(Color.orange.ignoresSafeArea())
}
} header: {
Text(section.title)
}
}
}
.listStyle(GroupedListStyle())
}
}
}
Although it is not documented in Apple's documentation, setting a .listRowBackground will wisely remove selection behaviour. What should happen if you set a background of Color.grey which matches the default selection color? Should Apple pick a different color now? How can they be sure the contrast is high enough for the user to tell if the selection is active?
Fortunately you can implement your own selection behaviour using List(selection:, content:) and then comparing the item being rendered in ForEach with the current selected item and changing the background yourself:
struct ContentView: View {
#State var selection: Int?
var body: some View {
List(selection: $selection) {
ForEach(1...5, id: \.self) { i in
Text(i, format: .number)
.listRowBackground(i == selection ? Color.red.opacity(0.5) : .white)
.tag(i)
}
}
}
}
Here it is in action:

SwiftUI selection in lists not working on reused cells

Consider the following project with two views. The first view presents the second one:
import SwiftUI
struct ContentView: View {
private let data = 0...1000
#State private var selection: Set<Int> = []
#State private var shouldShowSheet = false
var body: some View {
self.showSheet()
//self.showPush()
}
private func showSheet() -> some View {
Button(action: {
self.shouldShowSheet = true
}, label: {
Text("Selected: \(selection.count) items")
}).sheet(isPresented: self.$shouldShowSheet) {
EditFormView(selection: self.$selection)
}
}
private func showPush() -> some View {
NavigationView {
Button(action: {
self.shouldShowSheet = true
}, label: {
NavigationLink(destination: EditFormView(selection: self.$selection),
isActive: self.$shouldShowSheet,
label: {
Text("Selected: \(selection.count) items")
})
})
}
}
}
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
#State private var editMode: EditMode = .active
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
Text("\(value)")
}
}.environment(\.editMode, self.$editMode)
}
}
Steps to reproduce:
Create an app with the above two views
Run the app and present the sheet with the editable list
Select some items at random indexes, for example a handful at index 0-10 and another handful at index 90-100
Close the sheet by swiping down/tapping back button
Open the sheet again
Scroll to indexes 90-100 to view the selection in the reused cells
Expected:
The selected indexes as you had will be in “selected state”
Actual:
The selection you had before is not marked as selected in the UI, even though the binding passed to List contains those indexes.
This occurs both on the “sheet” presentation and the “navigation link” presentation.
If you select an item in the list, the “redraw” causes the original items that were originally not shown as selected to now be shown as selected.
Is there a way around this?
It looks like EditMode bug, worth submitting feedback to Apple. The possible solution is to use custom selection feature.
Here is a demo of approach (modified only part). Tested & worked with Xcode 11.4 / iOS 13.4
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
self.cell(for: value)
}
}
}
// also below can be separated into standalone view
private func cell(for value: Int) -> some View {
let selected = self.selection.contains(value)
return HStack {
Image(systemName: selected ? "checkmark.circle" : "circle")
.foregroundColor(selected ? Color.blue : nil)
.font(.system(size: 24))
.onTapGesture {
if selected {
self.selection.remove(value)
} else {
self.selection.insert(value)
}
}.padding(.trailing, 8)
Text("\(value)")
}
}
}

SwiftUI dynamic List with #Binding controls

How do I build a dynamic list with #Binding-driven controls without having to reference the array manually? It seems obvious but using List or ForEach to iterate through the array give all sorts of strange errors.
struct OrderItem : Identifiable {
let id = UUID()
var label : String
var value : Bool = false
}
struct ContentView: View {
#State var items = [OrderItem(label: "Shirts"),
OrderItem(label: "Pants"),
OrderItem(label: "Socks")]
var body: some View {
NavigationView {
Form {
Section {
List {
Toggle(items[0].label, isOn: $items[0].value)
Toggle(items[1].label, isOn: $items[1].value)
Toggle(items[2].label, isOn: $items[2].value)
}
}
}.navigationBarTitle("Clothing")
}
}
}
This doesn't work:
...
Section {
List($items, id: \.id) { item in
Toggle(item.label, isOn: item.value)
}
}
...
Type '_' has no member 'id'
Nor does:
...
Section {
List($items) { item in
Toggle(item.label, isOn: item.value)
}
}
...
Generic parameter 'SelectionValue' could not be inferred
Try something like
...
Section {
List(items.indices) { index in
Toggle(self.items[index].label, isOn: self.$items[index].value)
}
}
...
While Maki's answer works (in some cases). It is not optimal and it's discouraged by Apple. Instead, they proposed the following solution during WWDC 2021:
Simply pass a binding to your collection into the list, using the
normal dollar sign operator, and SwiftUI will pass back a binding to
each individual element within the closure.
Like this:
struct ContentView: View {
#State var items = [OrderItem(label: "Shirts"),
OrderItem(label: "Pants"),
OrderItem(label: "Socks")]
var body: some View {
NavigationView {
Form {
Section {
List($items) { $item in
Toggle(item.label, isOn: $item.value)
}
}
}.navigationBarTitle("Clothing")
}
}
}

How do I efficiently filter a long list in SwiftUI?

I've been writing my first SwiftUI application, which manages a book collection. It has a List of around 3,000 items, which loads and scrolls pretty efficiently. If use a toggle control to filter the list to show only the books I don't have the UI freezes for twenty to thirty seconds before updating, presumably because the UI thread is busy deciding whether to show each of the 3,000 cells or not.
Is there a good way to do handle updates to big lists like this in SwiftUI?
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
ForEach(userData.bookList) { book in
if !self.userData.showWantsOnly || !book.own {
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
Have you tried passing a filtered array to the ForEach. Something like this:
ForEach(userData.bookList.filter { return !$0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) { BookRow(book: book) }
}
Update
As it turns out, it is indeed an ugly, ugly bug:
Instead of filtering the array, I just remove the ForEach all together when the switch is flipped, and replace it by a simple Text("Nothing") view. The result is the same, it takes 30 secs to do so!
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if self.userData.showWantsOnly {
Text("Nothing")
} else {
ForEach(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
}
Workaround
I did find a workaround that works fast, but it requires some code refactoring. The "magic" happens by encapsulation. The workaround forces SwiftUI to discard the List completely, instead of removing one row at a time. It does so by using two separate lists in two separate encapsualted views: Filtered and NotFiltered. Below is a full demo with 3000 rows.
import SwiftUI
class UserData: ObservableObject {
#Published var showWantsOnly = false
#Published var bookList: [Book] = []
init() {
for _ in 0..<3001 {
bookList.append(Book())
}
}
}
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if userData.showWantsOnly {
Filtered()
} else {
NotFiltered()
}
}
}.navigationBarTitle(Text("Books"))
}
}
struct Filtered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList.filter { $0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct NotFiltered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct Book: Identifiable {
let id = UUID()
let own = Bool.random()
}
struct BookRow: View {
let book: Book
var body: some View {
Text("\(String(book.own)) \(book.id)")
}
}
struct BookDetail: View {
let book: Book
var body: some View {
Text("Detail for \(book.id)")
}
}
Check this article https://www.hackingwithswift.com/articles/210/how-to-fix-slow-list-updates-in-swiftui
In short the solution proposed in this article is to add .id(UUID()) to the list:
List(items, id: \.self) {
Text("Item \($0)")
}
.id(UUID())
"Now, there is a downside to using id() like this: you won't get your update animated. Remember, we're effectively telling SwiftUI the old list has gone away and there's a new list now, which means it won't try to move rows around in an animated way."
I think we have to wait until SwiftUI List performance improves in subsequent beta releases. I’ve experienced the same lag when lists are filtered from a very large array (500+) down to very small ones. I created a simple test app to time the layout for a simple array with integer IDs and strings with Buttons to simply change which array is being rendered - same lag.
Instead of a complicated workaround, just empty the List array and then set the new filters array. It may be necessary to introduce a delay so that emptying the listArray won't be omitted by the followed write.
List(listArray){item in
...
}
self.listArray = []
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.listArray = newList
}
Looking for how to adapt Seitenwerk's response to my solution, I found a Binding extension that helped me a lot. Here is the code:
struct ContactsView: View {
#State var stext : String = ""
#State var users : [MockUser] = []
#State var filtered : [MockUser] = []
var body: some View {
Form{
SearchBar(text: $stext.didSet(execute: { (response) in
if response != "" {
self.filtered = []
self.filtered = self.users.filter{$0.name.lowercased().hasPrefix(response.lowercased()) || response == ""}
}
else {
self.filtered = self.users
}
}), placeholder: "Buscar Contactos")
List{
ForEach(filtered, id: \.id){ user in
NavigationLink(destination: LazyView( DetailView(user: user) )) {
ContactCell(user: user)
}
}
}
}
.onAppear {
self.users = LoadUserData()
self.filtered = self.users
}
}
}
This is the Binding extension:
extension Binding {
/// Execute block when value is changed.
///
/// Example:
///
/// Slider(value: $amount.didSet { print($0) }, in: 0...10)
func didSet(execute: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
execute($0)
self.wrappedValue = $0
}
)
}
}
The LazyView is optional, but I took the trouble to show it, as it helps a lot in the performance of the list, and prevents swiftUI from creating the NavigationLink target content of the whole list.
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
This code will work correctly provided that you initialize your class in the 'SceneDelegate' file as follows:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var userData = UserData()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView:
contentView
.environmentObject(userData)
)
self.window = window
window.makeKeyAndVisible()
}
}