SwiftUI initializing state doesn't update after first init

I have a view which sends its size to a child, so the child can update its size relative to its parent. (The child needs to be able to respond to the parents size, and also update its own size from a drag event.)

In the example below, the child sets its width to 40% of the parent in it's init. When the window is first shown, the child updates its width correctly. When you resize the window, however, the initializer is called and the size is recalculated, but the child never updates its width. The state seems to never update.

When the code is called outside of the initializer (such as pressing a button) then the state updates correctly.

Why doesn't the state update in the init after its called more than once? How would I accomplish this?

Code Block swift
struct ContentView: View {
    var body: some View {
        GeometryReader { geo in
            ZStack {
                MyView(containerSize: geo.size)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .border(Color.green, width: 1)
        }
    }
}
struct MyView: View {
    let containerSize: CGSize
    @State var realSize: CGSize
    init(containerSize: CGSize) {
        self.containerSize = containerSize
        let newSize = CGSize(width: containerSize.width * 0.4, height: containerSize.height)
        print("INIT", newSize)
        _realSize = State(initialValue: newSize)
    }
    func updateWidth() {
        realSize = CGSize(width: containerSize.width * 0.4, height: containerSize.height)
    }
    var body: some View {
        ZStack {
            Button(action: {
                self.updateWidth()
            }) {
                Text("Update Width")
            }
        }
        .frame(width: realSize.width, height: realSize.height)
        .background(Color.blue)
    }
}


Replies

I tested your code.
I first had to change color from blue to yellow so that button is readable.

I added a print in update().

What I saw:
  • INIT is called once

When the code is called outside of the initializer (such as pressing a button) then the state updates correctly.

Which button do you mean ? Not Update Width button ?
Could you show the code of such a button ?

Why do you expect init to be called again ? Could you show where you expected it ?

Why doesn't the state update in the init after its called more than once?


Frankly, I was sort of astonished when I first found this behavior.

But considering how SwiftUI works and manages @State variables, I have realized it is quite natural.

Consider the following sample code:
Code Block
struct ContentView: View {
    var body: some View {
        GeometryReader { geo in
            ZStack {
                MyView(containerSize: geo.size)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .border(Color.green, width: 1)
        }
    }
}
struct MyView: View {
    let containerSize: CGSize
    var realSize: CGSize
    @State var text: String
    init(containerSize: CGSize) {
        self.containerSize = containerSize
        realSize = CGSize(width: containerSize.width * 0.4, height: containerSize.height)
        print("INIT", realSize)
        _text = State(initialValue: "")
    }
    var body: some View {
        VStack {
            Text("TextField")
            TextField("Please input text.", text: $text)
        }
        .frame(width: realSize.width, height: realSize.height)
        .background(Color.blue)
    }
}

Input some text to the text field, and resize the window.
You will find that MyView.init is being called while resizing, but the text held in the text field will not be reset to "".

That is the nature of @State variables of SwiftUI. It is the right behavior that SwiftUI initializing state doesn't update after first init.

How would I accomplish this?


Not using @State, as shown in the example above, might be one way. But that may be different than you expect, though I'm not sure.

How to accomplish this?

By using geometry reader to get the parent width instead of using @State variables.

Code Block
struct ContentView: View {
    var body: some View {
MyView()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .border(Color.green, width: 1)
    }
}
struct MyView: View {
    var body: some View {
        GeometryReader { proxy in
Button("Update Width") {}
.frame(width: proxy.size.width * 0.4, height: proxy.size.height)
.background(Color.blue)
        }
    }
}


Thanks everyone,

I understand why that's happening way better now.

The problem is that I need state because the view is draggable. Specifically I'm attempting to create a draggable box that is positioned by percentages within its container. So just initializing its size in the init without state doesn't work.

Is there a way to force update state? Or some method that can be called outside of the init (such as a resize event from the container)? I tried using on appear but just like state it only gets called once.

Here's an example that better shows what I need to accomplish, and the problem:

Code Block
struct ContentView: View {
var body: some View {
GeometryReader { geo in
ZStack(alignment: .topLeading) {
/* There are multiple of these: */
DraggableBox(containerSize: geo.size)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.border(Color.green, width: 1)
}
}
}
struct DraggableBox: View {
var containerSize: CGSize
@State var realSize: CGRect = .zero
/* This needs to be called when the parent changes size */
/* It's called when the view appears, but doesn't udpate when the window changes size */
func updateSize() {
let newSize = CGRect(x: realSize.minX, y: realSize.minY, width: containerSize.width * 0.4, height: containerSize.height * 0.4)
realSize = newSize
}
func onDrag(mousePos: CGPoint, startPos: CGPoint) {
/* Minimal working example of how the drag stuff works */
/* In reality the x and y position are also a percentage relative to the container like the width */
let x = mousePos.x
let y = mousePos.y
let newSize = CGRect(x: x, y: y, width: containerSize.width * 0.4, height: containerSize.height * 0.4)
self.realSize = newSize
}
var body: some View {
VStack {
Text("Drag me!")
Button(action: { self.updateSize() }, label: { Text("Fix relative size") })
}
.frame(width: realSize.width, height: realSize.height)
.background(Color.blue)
.offset(x: realSize.minX, y: realSize.minY)
.gesture(
DragGesture()
.onChanged({ (value) in
self.onDrag(mousePos: value.location, startPos: value.startLocation)
})
)
.onAppear {
self.updateSize()
}
}
}

I don't have a good solution, but I want to say that I've used SwiftUI a lot this year and I've struggled with similar situations, mostly related to non-trivial layout like your case here. I hope we see some new stuff next week along these lines and others.


Here's an example that better shows what I need to accomplish, and the problem:

Thanks for clarification.
You may want to try this:
Code Block
class ViewMetrics: ObservableObject {
    @Published var containerSize: CGSize = .zero
}
struct DraggableBox: View {
    @ObservedObject var viewMetrics: ViewMetrics = ViewMetrics()
    var containerSize: CGSize {viewMetrics.containerSize}
    @State var realSize: CGRect = .zero
    init(containerSize: CGSize) {
        viewMetrics.containerSize = containerSize
    }
    func updateSize() {
        let newSize = CGRect(x: realSize.minX, y: realSize.minY, width: containerSize.width * 0.4, height: containerSize.height * 0.4)
        realSize = newSize
    }
    func onDrag(mousePos: CGPoint, startPos: CGPoint) {
        /* Minimal working example of how the drag stuff works */
        /* In reality the x and y position are also a percentage relative to the container like the width */
        let x = mousePos.x
        let y = mousePos.y
        let newSize = CGRect(x: x, y: y, width: containerSize.width * 0.4, height: containerSize.height * 0.4)
        self.realSize = newSize
    }
    var body: some View {
        VStack {
            Text("Drag me!")
            Button(action: { self.updateSize() }, label: { Text("Fix relative size") })
        }
        .frame(width: realSize.width, height: realSize.height)
        .background(Color.blue)
        .offset(x: realSize.minX, y: realSize.minY)
        .gesture(
            DragGesture()
                .onChanged({ (value) in
                    self.onDrag(mousePos: value.location, startPos: value.startLocation)
                })
        )
        .onReceive(viewMetrics.$containerSize) {_ in
            self.updateSize()
        }
    }
}


I think you might be mixing two different states in an incorrect manner. OK, you need @State for MyView's position that you want to drag around. But the size of it's parent should not be included as a @State variable in this View. It is someone else's state and you should not duplicate one state multiple times.

You can read the size of the parent using a GeometryReader or you can pass its size using constants. Code example of both possibilities in combination with draggable position are listed below

Reading parent size using GeometryReader

Code Block
struct ContentView: View {
var body: some View {
MyView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.border(Color.green, width: 1)
}
}
struct MyView: View {
@State var relativePosition = CGPoint(x: 0.20, y: 0.2)
@State var relativeSize = CGSize(width: 0.1, height: 0.1)
var body: some View {
GeometryReader(content: rectangle(parent:))
}
func rectangle(parent: GeometryProxy) -> some View {
let absoluteX = relativePosition.x * parent.size.width
let absoluteY = relativePosition.y * parent.size.height
return Rectangle()
.frame(width: parent.size.width * relativeSize.width, height: parent.size.height * relativeSize.height)
.position(x: absoluteX, y: absoluteY)
.gesture(dragGesture(parentSize: parent.size))
}
func dragGesture(parentSize: CGSize) -> some Gesture {
DragGesture().onChanged { (drag) in
let relativeX = drag.location.x / parentSize.width
let relativeY = drag.location.y / parentSize.height
self.relativePosition = CGPoint(x: relativeX, y: relativeY)
}
}
}

Passing the size using constants

Code Block
struct ContentView: View {
var body: some View {
GeometryReader { proxy in
MyView(parentSize: proxy.size)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.border(Color.green, width: 1)
}
}
}
struct MyView: View {
let parentSize: CGSize
@State var relativePosition = CGPoint(x: 0.20, y: 0.2)
@State var relativeSize = CGSize(width: 0.1, height: 0.1)
var body: some View {
Rectangle()
.frame(width: parentSize.width * relativeSize.width, height: parentSize.height * relativeSize.height)
.position(absolutePosition)
.gesture(dragGesture)
}
var dragGesture: some Gesture {
DragGesture().onChanged { (drag) in
let relativeX = drag.location.x / self.parentSize.width
let relativeY = drag.location.y / self.parentSize.height
self.relativePosition = CGPoint(x: relativeX, y: relativeY)
}
}
var absolutePosition: CGPoint {
CGPoint(
x: relativePosition.x * parentSize.width,
y:  relativePosition.y * parentSize.height
)
}
}

Faced a similar problem today. Pay attention to the onChange() block.

Solution:

struct MyView: View {
   
  let realSizeTmp: CGSize
  @State var realSize: CGSize
   
  init(containerSize: CGSize) {
    let newSize = CGSize(width: containerSize.width * 0.4, height: containerSize.height)
    _realSize = State(initialValue: newSize)
    realSizeTmp = newSize
  }
   
  var body: some View {
    ZStack {
    }
    .onChange(of: realSizeTmp) { newValue in
      realSize = newValue
    }
    .frame(width: realSize.width, height: realSize.height)
    .background(Color.red)
  }
}