7

How is it possible to set a @State var inside a geometryReader?

This is my code:

@State var isTest:Int = 0

var body: some View {
    VStack{
        ForEach(self.test, id: \.id) { Test in
            VStack{
                GeometryReader { geometry in
                    self.isTest = 1

I try with a function but it doesn't work.

@State var isTest: Int = 0

func testValue() {
    self.isTest = 1
}

var body: some View {
    VStack{
        ForEach(self.test, id: \.id) { Test in
            VStack{
                GeometryReader { geometry in
                    testValue()

Any idea? Thanks!

Max Desiatov
  • 5,087
  • 3
  • 48
  • 56
Stefano Vet
  • 567
  • 1
  • 7
  • 18

6 Answers6

5

You can use onAppear(perform:) to update @State variables with the initial view size and onChange(of:perform:) to update the variables when the view size changes:

struct MyView: View {
  @State private var size: CGSize = .zero

  var body: some View {
    GeometryReader { geometry in
      ZStack {
        Text("Hello World")
      }.onAppear {
        size = geometry.size
      }.onChange(of: geometry.size) { newSize in
        size = newSize
      }
    }
  }
}
Madiyar
  • 779
  • 11
  • 7
  • Simple, clean and it works as far as I can tell. The .onAppear trick was mentioned before but .onChange seems necessary to update the geometry when it changes. – Stefan K. Jan 31 '23 at 08:54
3

I also had a similar problem yesterday. But I was trying to pass a value from inside the GeometryReader.

I tried a couple of ways but it didn't work. When I use @State var to declare the variable, the compiler again complained in a purple line saying that Modifying the view during update will make it become Undefined.

When I tried to declare a variable using var only, the compiler just told me that it's immutable.

And then, I tried storing it onto my @EnvironmentObject. And I just got a dead loop.

So, my last hope was using the notification way and some how it worked. But I don't know if it's the standard way of implementation.

@State private var viewPositionY:CGFloat = 0

First, post the value frame.origin.y via notification.

 GeometryReader{ geometry -> Text in
        let frame = geometry.frame(in: CoordinateSpace.global)
        NotificationCenter.default.post(name: Notification.Name("channelBlahblahblah"), object:nil, userInfo:["dict":frame.origin.y])
        return Text("My View Title")
 }

And then declare a publisher to receive the notification.

private let myPublisher = NotificationCenter.default.publisher(for: Notification.Name("channelBlahblahblah"))

Finally, use the the .onReceive modifier to receive the notification.

.onReceive(myPublisher) { (output) in
           
   viewPositionY = output.userInfo!["dict"] as! CGFloat
   //you can do you business here
}
William Tong
  • 479
  • 7
  • 14
3

While putting code into function is a nice touch, there may arrive another problem and that is altering the @State variable during update phase: [SwiftUI] Modifying state during view update, this will cause undefined behavior

Using NotificationCenter to move @State variable update after view update phase can help, but one could use much more simple solution like performing variable update right after render phase by using DispatchQueue.

@State var windowSize = CGSize()

func useProxy(_ geometry: GeometryProxy) -> some View {

    DispatchQueue.main.async {
        self.windowSize = geometry.size
    }

    return EmptyView()
}

var body: some View {

    return GeometryReader { geometry in
        self.useProxy(geometry)    

        Text("Hello SwiftUI")        
    }
}
vedrano
  • 2,961
  • 1
  • 28
  • 25
1

You can update @State variables in the onAppear method if you need the initial geometry values

@State var windowSize = CGSize()

var body: some View {

    return GeometryReader { geometry in 
        VStack {
            Text("Hello SwiftUI")  
        }
        .onAppear {
            windowSize = geometry.size
        }      
    }
}
1

Try this

@State private var viewSize: CGSize = .zero

var body: some View {
    VStack {
        // ...
    }
    .background(GeometryReader { proxy in
        Color.clear.preference(
            key: ViewSizePreferenceKey.self,
            value: proxy.size
        )
    })
    .onPreferenceChange(ViewSizePreferenceKey.self) { size in
        viewSize = size
    }
}

private struct ViewSizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = value.width + value.height > nextValue().width + nextValue().height ? value : nextValue()
    }
}
mishimay
  • 4,237
  • 1
  • 27
  • 23
0

So it's totally possible to update a @State inside a GeometryReader. The solution is simple. However, there's a caveat:

you might end up with an infinite loop (nothing too troublesome, I'll present a solution here)

You'll just need a DispatchQueue.main.async and explicitly declare the type of the view inside GeometryReader. If you execute the View below (don't forget to stop it) you'll see that it never stops updating the value of the Text.

NOT THE FINAL SOLUTION:

struct GenericList: View {
    @State var timesCalled = 0

    var body: some View {
        GeometryReader { geometry -> Text in
            DispatchQueue.main.async {
                timesCalled += 1 // infinite loop
            }
            return Text("\(timesCalled)")
        }
    }
}

This happens because the View will "draw" the GeometryReader, which will update a @State of the View. Thus, the new @State invalidates the View causing the View to be redrawn. Consequently going back to the first step (drawing the GeometryReader and updating the state).

To solve this you need to put some constraints in the draw of the GeometryReader. Instead of returning your View inside the GeometryReader, draw it then add the GeometryReader as a transparent overlay or background. This will have the same effect but you'll be able to put constraints in the presentation.

Personally, I'd rather use an overlay because you can add as many as you want. Note that an overlay does not permit an if else inside of it, but it is possible to call a function. That's why there's the func geometryReader() below. Another thing is that in order to return different types of Views you'll need to add @ViewBuilder before it.

In this code, the GeometryReader is called only once and you get the @State var timesCalled updated.

FINAL SOLUTION:

struct GenericList: View {
    @State var timesCalled = 0
    
    @ViewBuilder
    func geometryReader() -> some View {
        if timesCalled < 1 {
            GeometryReader { geometry -> Color in
                DispatchQueue.main.async {
                    timesCalled += 1
                }
                return Color.clear
            }
        } else {
            EmptyView()
        }
    }

    var body: some View {
        Text("\(timesCalled)")
            .overlay(geometryReader())
    }
}

Note: you don't need to put the same constraints, for example, in my case, I wanted to move the view with the drag gesture. So, I've put the constraints to start when the user touches down and to stop when the user ends the drag gesture.

Renê X
  • 59
  • 3
  • Can you skip to the final solution? ;) – Vega Jun 06 '21 at 08:57
  • 1
    It's actually there, marked with FINAL SOLUTION. The core elements to that are: `@State var timesCalled = 0` a state to control how many times you want to get the frame (might as well be a bool if you want only one) `@ViewBuilder` - so you can return different types of views in the function `func geometryReader() -> some View {` - a func that returns the `GeometryReader` or an emptyview `if timesCalled < 1 {` - at last, the condition to stop getting the frame – Renê X Jun 06 '21 at 17:51