Breaking down #ViewBuilder TupleView - swiftui

I'm trying to understand how Apple extracts the individual views from ViewBuilder TupleViews. Specifically with things like List that support lazy loading of views.
I have managed to build a lazy loading UIViewRepresentable table view using reflection (done with some slight modifications to GeorgeElsham's ViewExtractor https://github.com/GeorgeElsham/ViewExtractor/pull/4/files). However, I'm still trying to find a way to do it without reflection.
A solution like this feels close to how Apple is probably handling it, given the 10 view limit they have on view builders:
https://stackoverflow.com/a/64426470/1877760
init<V0: View, V1: View>(
#ViewBuilder content: #escaping () -> TupleView<(V0, V1)>
) {
let cv = content().value
inputViews = [AnyView(cv.0), AnyView(cv.1)]
}
init<V0: View, V1: View, V2: View>(
#ViewBuilder content: #escaping () -> TupleView<(V0, V1, V2)>) {
let cv = content().value
inputViews = [AnyView(cv.0), AnyView(cv.1), AnyView(cv.2)]
}
init<V0: View, V1: View, V2: View, V3: View>(
#ViewBuilder content: #escaping () -> TupleView<(V0, V1, V2, V3)>) {
let cv = content().value
inputViews = [AnyView(cv.0), AnyView(cv.1), AnyView(cv.2), AnyView(cv.3)]
}
...
The issue I've found with this sort of solution is that ForEach blocks are treated as single views, and so all of their content is put into a single row in the table view (and therefore also loaded all at once). I'm not too sure how to proceed without using reflection to see if a given view in a TupleView is a ForEach or not.
List can handle things like this, so I'm guessing Apple is drilling down recursively.
List {
ForEach(0..<5) { index1 in
ForEach(0..<2) { index2 in
ForEach(0..<2) { index3 in
Text("\(index1)\(index2)\(index3)")
}
}
}
}
I guess to put a clear definition on the goal:
Given a ViewBuilder with between 0-10 views inside of it (of which any or all can be ForEach views, of which can be nested), return an array of the views (with ForEach's content views expanded out).
Does anyone know how Apple does it?

Related

ForEach: ID parameter and closure return type

So, I'm going through the SwiftUI documentation to get familiar. I was working on a grid sample app. It has the following code:
ForEach(allColors, id: \.description) { color in
Button {
selectedColor = color
} label: {
RoundedRectangle(cornerRadius: 4.0)
.aspectRatio(1.0, contentMode: ContentMode.fit)
.foregroundColor(color)
}
.buttonStyle(.plain)
}
It didn't occur to me first that ForEach is actually a struct, I thought it's a variation of the for in loop at first so I'm quite new at this. Then I checked the documentation.
When I read the documentation and some google articles for the ForEach struct, I didn't understand two points in the code:
So we are initializing the foreach struct with an array of colors. For the the ID why did they use .\description instead of .self?
Second is using color in. Since foreach is a struct and the paranthesis is the initializtion parameters this looks like the return type of a closure but why would we return individual colors to foreach? I thought the return is a collection of views or controls like button and label. This is like var anInteger: Int = 1 for example. What type does ForEach accept as a result of the closure? Or am I reading this all wrong?
So we are initializing the foreach struct with an array of colors. For the the ID why did they use .\description instead of .self?
It depends on the type of allColors. What you should have in mind that id here is expected to be stable. The documentation states:
It’s important that the id of a data element doesn’t change unless you replace the data element with a new data element that has a new identity. If the id of a data element changes, the content view generated from that data element loses any current state and animations.
So for example if colors are reference types (which are identifiable) and you swap one object with an identical one (in terms of field values), the identity will change, whereas description wouldn't (for the purposes of this example - just assuming intentions of code I have no access to).
Edit: Also note that in this specific example allColors appears to be a list of Color, which is not identifiable. So that's the reason behind the custom id keyPath.
Regarding your second point, note that the trailing closure is also an initialization parameter. To see this clearly we could use the "non-sugared" version:
ForEach(allColors, id: \.description, content: { color in
Button {
selectedColor = color
} label: {
RoundedRectangle(cornerRadius: 4.0)
.aspectRatio(1.0, contentMode: ContentMode.fit)
.foregroundColor(color)
}
.buttonStyle(.plain)
})
where content is a closure (an anonymous function) that gets passed an element of the collection and returns some View.
So the idea is something like this: "Give me an collection of identifiable elements and I will call a function for each of these elements expecting from you to return me some View".
I hope that this makes (some) sense.
Additional remarks regarding some of the comments:
It appears to me that the main source of confusion is the closure itself. So let's try something else. Let's write the same code without a closure:
ForEach's init has this signature:
init(_ data: Data, id: KeyPath<Data.Element, ID>, content: #escaping (Data.Element) -> Content)
Now, the content translates to:
A function with one parameter of type Data.Element, which in our case is inferred from the data so it is a Color. The function's return type is Content which is a view builder that produces some View
so our final code, which is equivalent to the first one, could look like this:
struct MyView: View {
let allColors: [Color] = [.red, .green, .blue]
#State private var selectedColor: Color?
var body: some View {
List {
ForEach(allColors, id: \.description, content: colorView)
}
}
#ViewBuilder
func colorView(color: Color) -> some View {
Button {
selectedColor = color
} label: {
RoundedRectangle(cornerRadius: 4.0)
.aspectRatio(1.0, contentMode: ContentMode.fit)
.foregroundColor(color)
}
.buttonStyle(.plain)
}
}
I hope that this could help to clarify things a little bit better.

How to respond to hover / click event on AttributedString in SwiftUI

I am using AttributedString in SwiftUI (Mac application) to customize the appearance of portions of a long string. I'm displaying the text formatted successfully and it appears correct.
My code looks like this:
struct TextView: View {
var body: some View {
ScrollView {
Text(tag())
}.padding()
}
func tag() -> AttributedString {
// code which creates the attributed string and applies formatting to various locations
}
}
At this point I want to add "touch points" ("interactive points") to the text (imagine hyperlinks) which will provide additional information when particular locations (pieces of text) are interacted with.
Ive seen some similar questions describing usage (or combinations) of NSTextAttachment , NSAttributedStringKey.link , UITextViewDelegate
see:
NSAttributedString click event in UILabel using Swift
but this isn't (or at least not obvious) the idiomatic "SwiftUI" way and seems cumbersome.
I would want to tag the string with the formatting while adding the "Attachment" which can be recognized in the view event handler:
func tag() -> AttributedString {
// loose for this example
var attributedString = AttributedString("My string which is very long")
for range in getRangesOfAttributes {
attributedString[range].foregroundColor = getRandomColor()
attributedString[range].attachment = Attachment() <<<<<<< this is missing, how do I tag this portion and recognize when it got interacted with in the View
}
}
func getRangesOfAttributes() -> ClosedRange<AttributedString.Index> {
... returns a bunch of ranges which need to be tagged
}
// the view can now do something once the attachment is clicked
var body: View {
Text(tag())
.onClickOfAttachment(...) // <<<< This is contrived, how can I do this?
}

ForEach - Index out of range?

Why running this code shows "Fatal error: Index out of range"?
import SwiftUI
struct MyData {
var numbers = [Int](repeating: 0, count: 5)
}
#main
struct TrySwiftApp: App {
#State var myData = MyData()
var body: some Scene {
WindowGroup {
ChildView(myData: myData)
.frame(width: 100, height: 100)
.onAppear {
myData.numbers.removeFirst() // change myData
}
}
}
}
struct ChildView: View {
let myData: MyData // a constant
var body: some View {
ForEach(myData.numbers.indices) {
Text("\(myData.numbers[$0])") // Thread 1: Fatal error: Index out of range
}
}
}
After checking other questions,
I know I can fix it by following ways
// fix 1: add id
ForEach(myData.numbers.indices, id: \.self) {
//...
}
or
// Edited:
//
// This is not a fix, see George's reply
//
// fix 2: make ChildView conforms to Equatable
struct ChildView: View, Equatable {
static func == (lhs: ChildView, rhs: ChildView) -> Bool {
rhs.myData.numbers == rhs.myData.numbers
}
...
My Questions:
How a constant value (defined by let) got out of sync?
What ForEach really did?
Let me give you a simple example to show you what happened:
struct ContentView: View {
#State private var lowerBound: Int = 0
var body: some View {
ForEach(lowerBound..<11) { index in
Text(String(describing: index))
}
Button("update") { lowerBound = 5 }.padding()
}
}
if you look at the upper code you would see that I am initializing a ForEach JUST with a Range like this: lowerBound..<11 which it means this 0..<11, when you do this you are telling SwiftUI, hey this is my range and it will not change! It is a constant Range! and SwiftUI says ok! if you are not going update upper or lower bound you can use ForEach without showing or given id! But if you see my code again! I am updating lowerBound of ForEach and with this action I am breaking my agreement about constant Range! So SwiftUI comes and tell us if you are going update my ForEach range in count or any thing then you have to use an id then you can update the given range! And the reason is because if we have 2 same item with same value, SwiftUI would have issue to know which one you say! with using an id we are solving the identification issue for SwiftUI! About id you can use it like this: id:\.self or like this id:\.customID if your struct conform to Hash-able protocol, or in last case you can stop using id if you confrom your struct to identifiable protocol! then ForEach would magically sink itself with that.
Now see the edited code, it will build and run because we solved the issue of identification:
struct ContentView: View {
#State private var lowerBound: Int = 0
var body: some View {
ForEach(lowerBound..<11, id:\.self) { index in
Text(String(describing: index))
}
Button("update") { lowerBound = 5 }.padding()
}
}
Things go wrong when you do myData.numbers.removeFirst(), because now myData.numbers.indices has changed and so the range in the ForEach showing Text causes problems.
You should see the following warning (at least I do in Xcode 13b5) hinting this could cause issues:
Non-constant range: not an integer range
The reason it is not constant is because MyData's numbers property is a var, not let, meaning it can change / not constant - and you do change this. However the warning only shows because you aren't directly using a range literal in the ForEach initializer, so it assumes it's not constant because it doesn't know.
As you say, you have some fixes. Solution 1 where you provide id: \.self works because now it uses a different initializer. Definition for the initializer you are using:
#available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a given constant
/// range.
///
/// The instance only reads the initial value of the provided `data` and
/// doesn't need to identify views across updates. To compute views on
/// demand over a dynamic range, use ``ForEach/init(_:id:content:)``.
///
/// - Parameters:
/// - data: A constant range.
/// - content: The view builder that creates views dynamically.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
Stating:
The instance only reads the initial value of the provided data and doesn't need to identify views across updates. To compute views on demand over a dynamic range, use ForEach/init(_:id:content:).
So that's why your solution 1 worked. You switched to the initializer which didn't assume the data was constant and would never change.
Your solution 2 isn't really a "solution". It just doesn't update the view at all, because myData.numbers changes so early that it is always equal, so the view never updates. You can see the view still has 5 lines of Text, rather than 4.
If you still have issues with accessing the elements in this ForEach and get out-of-bounds errors, this answer may help.

Prevent updateUIView() from being called in UIRepresentableView?

I think I need to change a boolean in a UIRepresentableView before removing it from the ContentView but can't find a way, since I can't mutate the struct properties.
Why do I need that? Because I need to prevent updateUIView() from being called one last time before the view being released.
So probably this is an XY problem... anyway, I'm lost.
I have a model like this:
class PlayerModel: NSObject, ObservableObject {
#Published var lottie: [LottieView]
Where LottieView is this:
struct LottieView: UIViewRepresentable {
var name: String
var loopMode: LottieLoopMode = .playOnce
func makeUIView(context: UIViewRepresentableContext<LottieView>) -> UIView {
let view = UIView()
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LottieView>) {
uiView.subviews.forEach({ sub in
sub.removeFromSuperview()
})
let animationView = AnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
uiView.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: uiView.widthAnchor),
animationView.heightAnchor.constraint(equalTo: uiView.heightAnchor)
])
animationView.animation = Animation.named(name, animationCache: LRUAnimationCache.sharedCache)
animationView.contentMode = .scaleAspectFit
animationView.loopMode = loopMode
animationView.play()
}
}
In my ContentView I only display a LottieView if there's a recent one in the published array:
struct ContentView: View {
#ObservedObject var model: PlayerModel
var body: some View {
NavigationView {
VStack {
// ...
if let lottie = model.lottie.last {
lottie
}
// ...
}
}
}
}
Now this works ok, but when in the model I remove the last LottieView from the published array, the updateUIView() func is called one last time by SwiftUI, resulting in the animation being recreated again just before the LottieView is removed from the view, resulting in a visible flicker.
So I thought I'd add a boolean that I could update just before removing the last LottieView from the published array, and do an if/else in the updateUIView(), but... is this even possible? I can't find a way.
Ideally I would just have the animation view created in makeUIView() and nothing done in updateUIView() but this is not possible, I need to remove the subviews first, otherwise the previous shown animation is not replaced by the new one.
While I would still love to have someone give me a proper solution to my issue, or tell me an alternative way of doing things, I found a workaround.
In the model, I was previously emptying the published array by doing
lottie = []
But if instead we do this:
lottie.removeAll()
We see no flicker anymore (however updateView() is still called!!).

SwiftUI UIViewRepresentable UITextView Binding

Multiline text input is currently not natively supported in SwiftUI (hopefully this feature is added soon!) so I've been trying to use the combine framework to implement a UITextView from UIKit which does support multiline input, however i've been having mixed results.
This is the code i've created to make the Text view:
struct MultilineTextView: UIViewRepresentable {
#Binding var text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
view.backgroundColor = UIColor.white
view.textColor = UIColor.black
view.font = UIFont.systemFont(ofSize: 17)
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
func frame(numLines: CGFloat) -> some View {
let height = UIFont.systemFont(ofSize: 17).lineHeight * numLines
return self.frame(height: height)
}
func makeCoordinator() -> MultilineTextView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: MultilineTextView
init(_ parent: MultilineTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
}
}
I've then implemented it in a swiftUI view like:
MultilineTextView(text: title ? $currentItem.titleEnglish : $currentItem.pairArray[currentPair].english)//.frame(numLines: 4)
And bound it to a state variable:
#State var currentItem:Item
It sort of works. However, the state var currentItem:Item contains an array of strings which I'm then cycling through using buttons which update the string array based on what has been inputted into MultilineTextView. This is where i'm encountering a problem where the MultilineTextView seems to bind to only the first string item in the array, and then it won't change. When I use swiftUI's native TextField view this functionality works fine and I can cycle through the string array and update it by inputting text into the TextField.
I think I must be missing something in the MultilineTextView struct to allow this functionality. Any pointers are gratefully received.
Update: Added model structs
struct Item: Identifiable, Codable {
let id = UUID()
var completed = false
var pairArray:[TextPair]
}
struct TextPair: Identifiable, Codable {
let id = UUID()
var textOne:String
var textTwo:String
}
Edit:
So I've done some more digging and I've found what I think is the problem. When the textViewDidChange of the UITextView is triggered, it does send the updated text which I can see in the console. The strange thing is that the updateUIView function then also gets triggered and it updates the UITextView's text with what was in the binding var before the update was sent via textViewDidChange. The result is that the UITextview just refuses to change when you type into it. The strange thing is that it works for the first String in the array, but when the item is changed it won't work anymore.
It appears that SwiftUI creates two variants of UIViewRepresentable, for each binding, but does not switch them when state, ie title is switched... probably due to defect, worth submitting to Apple.
I've found worked workaround (tested with Xcode 11.2 / iOS 13.2), use instead explicitly different views as below
if title {
MultilineTextView(text: $currentItem.titleEnglish)
} else {
MultilineTextView(text: $currentItem.pairArray[currentPair].textOne)
}
So I figured out the problem in the end, the reason why it wasn't updating was because I was passing in a string which was located with TWO state variables. You can see that in the following line, currentItem is one state variable, but currentPair is another state variable that provides an index number to locate a string. The latter was not being updated because it wasn't also being passed into the multiline text view via a binding.
MultilineTextView(text: title ? $currentItem.titleEnglish : $currentItem.pairArray[currentPair].english)
I thought initially that passing in one would be fine and the parent view would handle the other one but this turns out not to be the case. I solved my problem by making two binding variables so I could locate the string that I wanted in a dynamic way. Sounds stupid now but I couldn't see it at the time.