Search code examples
swiftui

Error "Geometry action is cycling between duplicate values."


The following code worked just fine as expected until I threw in an additional Text views write below the Toggle (it is commented out in the code below). When I do that I get a Geometry action is cycling between duplicate values. error and the program eventually hangs (on X86 15.2). It does not really hang but seems stuck in some infinite update loop consuming a 100% of a core and eating up RAM. Same error on M3 15.1 except haven't been able to make it hang/get stuck yet.

Seems like a new error since Google returns no results for it.

Anyone seen anything like that, if yes, what does it mean?

Is there a problem with the code or is this a sign of a possible bug in SwiftUI?

struct CaptureSizeView: ViewModifier {
  @Binding var size: CGSize

  func body(content: Self.Content) -> some View {
    content
      .onGeometryChange(for: CGSize.self) { proxy in proxy.size }
      action: { newValue in size = newValue }
  }
}

public extension View {
  func captureSize(_ size: Binding<CGSize>) -> some View {
    modifier(CaptureSizeView(size: size))
  }
}

public extension CGSize {
  var aspectRatio: CGFloat { self.width / self.height }
}

struct ContentView: View {
  @State private var clearSize: CGSize = .zero
  @State private var isSquare: Bool = false

  var body: some View {
    HStack(alignment: .top) {
      VStack(alignment: .leading) {
        Toggle("Keep Square", isOn: $isSquare).toggleStyle(.switch)
//  Uncomment: error "Geometry action is cycling between duplicate values."
//        Text("available size: \(clearSize)")
      }
      .padding()

      Divider()

      VStack {
        Text("Canvas")
          .font(.largeTitle)
          .fontWeight(.black)
        Text("available size: \(clearSize)")

        ZStack {
          Color.clear
            .border(.green)
            .captureSize($clearSize)
            .overlay {
              Canvas { _, _ in
              }
              .aspectRatio(isSquare ? 1 : clearSize.aspectRatio, contentMode: .fit)
              .background(.yellow)
            }
        }
        .padding()
      }
    }
  }
}

Solution

  • I was able to reproduce the problem by running your code on macOS 15.1.1 (a MacBook Pro with Apple M1 Pro).

    Your view is divided into a left and a right side:

    • The width of the left side is primarily determined by the Text output that displays the size. This is because the width of the text is greater than the width needed for the toggle control that is shown above it on the left side.
    • The right side has a ZStack with a Color (and a Canvas as overlay).
    • The greedy color causes the right side to consume all the width that remains after the left side has been given the width it needs.

    When the window size is changed, it causes the size of the ZStack to change. When the new size is displayed, the text (probably) has a different width to the size that was being displayed before, because a proportional font is being used to display the size. So the change in Text size causes another change to the ZStack size. The latest change to the ZStack size causes another change to the Text width. And so it goes on.

    To fix, you need to prevent a change in the size of the ZStack from causing a ripple effect that results in another change to the ZStack size.

    • One way would be to constrain the width of the left side of the view:
    VStack(alignment: .leading) {
        Toggle("Keep Square", isOn: $isSquare).toggleStyle(.switch)
        Text("available size: \(clearSize)")
    }
    .lineLimit(1) // 👈 added
    .frame(maxWidth: 250, alignment: .leading) // 👈 added
    .padding()
    
    • Alternatively, use string formatting to determine the decimal precision and a placeholder to reserve the space necessary for showing the largest conceivable size. The actual size can then be shown as an overlay over the placeholder:
    VStack(alignment: .leading) {
        Toggle("Keep Square", isOn: $isSquare).toggleStyle(.switch)
        Text("available size: 8888.888 x 8888.888")
            .hidden()
            .overlay(alignment: .leading) {
                Text("available size: ") +
                Text(String(format: "%.3f", clearSize.width)) +
                Text(" x ") +
                Text(String(format: "%.3f", clearSize.height))
            }
    }
    .padding()