4

I am trying to loop through an array in SwuftUI to render multiple text strings in different locations. Going through the array manually works, but using forEach-loop produces an error. In the code sample below, I have commented out the manual approach (which works). This kind of approach worked in this tutorial for drawing lines (https://developer.apple.com/tutorials/swiftui/drawing-paths-and-shapes)

(As a bonus question: is there a way to get the index/key of the individual positions through this approach, without adding that key in to the positions-Array for each of the rows?)

I have tried various approaches like ForEach, adding identified() in there as well, and adding various type definitions for the closure, but I just end up creating other errors

import SwiftUI

var positions: [CGPoint] = [
    CGPoint(x: 100, y: 100),
    CGPoint(x: 100, y: 200),
    CGPoint(x: 100, y: 300),
]

struct ContentView : View {
    var body: some View {
        ZStack {
            positions.forEach { (position) in
                Text("Hello World")
                    .position(position)
            }
/* The above approach produces error, this commented version works
           Text("Hello World")
                .position(positions[0])
            Text("Hello World")
                .position(positions[1])
            Text("Hello World")
                .position(positions[2]) */
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif

2 Answers 2

3

In the Apple tutorial you pointed to, they used the ´.forEach´ inside the Path closure, which is a "normal" closure. SwiftUI, uses a new swift feature called "function builders". The { brackets } after ZStack might look like a usual closure, but it's not!

See eg. https://www.swiftbysundell.com/posts/the-swift-51-features-that-power-swiftuis-api for more about function builders.

In essence, the "function builder" (more specifically, ViewBuilder, in this case; read more: https://developer.apple.com/documentation/swiftui/viewbuilder) get an array of all the statements in the "closure", or rather, their values. In ZStack, those values are expected to be conforming to the View protocol.

When you run someArray.forEach {...}, it will return nothing, void, also known as (). But the ViewBuilder expected something conforming to the View protocol! In other words:

Cannot convert value of type '()' to closure result type '_'

Of course it can't! Then, how might we do a loop/forEach that returns what we want?

Again, looking at the SwiftUI documentation, under "View Layout and Presentation" -> "Lists and Scroll Views", we get: ForEach, which allows us to describe the iteration declaratively, instead of imperatively looping through the positions: https://developer.apple.com/documentation/swiftui/foreach

When a view's state changes, SwiftUI regenerates the struct describing the view, compares it with the old struct, and then only makes the necessary patches to the actual UI, to save performance and allow for fancier animations, etc. To be able to do this, it needs to be able to identify each item in eg. a ForEach (eg. to distinguish an insert of a new point from just a change of an existing one). Thus, we can't just pass the array of CGPoints directly to ForEach (at least not without adding an extension to CGPoint, making them conform to the Identifiable protocol). We could make a wrapper struct:

import SwiftUI

var positions: [CGPoint] = [
    CGPoint(x: 100, y: 100),
    CGPoint(x: 100, y: 200),
    CGPoint(x: 100, y: 300),
]

struct Note: Identifiable {
    let id: Int
    let position: CGPoint
    let text: String
}

var notes = positions.enumerate().map { (index, position) in
    // using initial index as id during setup
    Note(index, position, "Point \(index + 1) at position \(position)")
}

struct ContentView: View {
    var body: some View {
        ZStack {
            ForEach(notes) { note in
                Text(note.text)
                    .position(note.position)
            }
        }
    }
}

We could then add the ability to tap-and-drag the notes. When tapping a note, we might want to move it to the top of the ZStack. If any animation was playing on the note (for instance, changing its position during drag), it would normally stop (because the whole note-view would be replaced), but because the note struct now is Identifiable, SwiftUI will understand that it's only been moved, and make the change without interfering with any animation.

See https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-views-in-a-loop-using-foreach or https://medium.com/@martinlasek/swiftui-dynamic-list-identifiable-73c56215f9ff for a more in depth tutorial :)

note: the code has not been tested (gah beta Xcode)

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks for very clear and easy to understand answer! Note, that I tried to add some definitions for what the .forEach loop returns, but those lead to even more confusing errors... now I understand why, and I should be using ForEach instead. Thanks once more!
3

Not everything is valid in a view body. If you would like to perform a for-each loop, you need to use the special view ForEach:

https://developer.apple.com/documentation/swiftui/foreach

The view requires an identifiable array, and the identifiable array requires elements to conform to Hashable. Your example would need to be rewritten like this:

import SwiftUI

extension CGPoint: Hashable {
    public func hash(into hasher: inout Hasher) {
        hasher.combine(x)
        hasher.combine(y)
    }
}
var positions: [CGPoint] = [
    CGPoint(x: 100, y: 100),
    CGPoint(x: 100, y: 200),
    CGPoint(x: 100, y: 300),
]

struct ContentView : View {
    var body: some View {
        ZStack {
            ForEach(positions.identified(by: \.self)) { position in
                Text("Hello World")
                    .position(position)
            }
        }
    }
}

Alternatively, if your array is not identifiable, you can get away with it:

var positions: [CGPoint] = [
    CGPoint(x: 100, y: 100),
    CGPoint(x: 100, y: 200),
    CGPoint(x: 100, y: 300),
]

struct ContentView : View {
    var body: some View {
        ZStack {
            ForEach(0..<positions.count) { i in
                Text("Hello World")
                    .position(positions[i])
            }
        }
    }
}

6 Comments

Thanks, that works!... So the reason that the approach I tried works in Apple's tutorial (developer.apple.com/tutorials/swiftui/drawing-paths-and-shapes) is that the loop is inside a Path? (you have to scroll down to Step 4 to see the use of .forEach)
Yes, you are correct. When laying out views, there are a lot of restrictions. It is not really pure swift.
Thank you! tested it myself as well and this works. Have to learn more basics... code struct ContentView : View { var body: some View { Path { path in path.move(to: positions[2]) positions.forEach { (position) in path.addLine(to: position) } } .fill(Color.black) } }code
I added another approach, that does not required the Hashable nor the Identifiable conformance.
Also note that your array has not been declared bindable, if it later changes, SwiftUI has no way of knowing it, and your view will not update. If you want your view to update with changes in the array, you need to create a BindableObject, a State or an EnvironmentObject. All are explained in detail in the WWDC2019 session 226 - Data Flow Through SwiftUI. Highly recommended you watch it.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.