0

Edited:

Sorry for the original long story, following is a short minimal reproducible standalone example I can think of:

import SwiftUI

extension View {
    /// get view's size and do something with it.
    func getSize(action: @escaping (CGSize) -> Void) -> some View {
        overlay(GeometryReader{ geo in
            emptyView(size: geo.size, action: action)
        })
    }
    
    // private empty view
    private func emptyView(
        size  : CGSize, 
        action: @escaping (CGSize) -> Void
    ) -> some View {
        action(size)        // ⭐️ side effect❗️
        return Color.clear
    }
}

struct MyView: View {
    @State private var size = CGSize(width: 300, height: 200)
    @State private var ratio: CGFloat = 1
    var body: some View {
        VStack {
            Spacer()
            cell
            Spacer()
            controls
        }
    }
    var cell: some View {
        Color.pink
            .overlay {
                VStack {
                    Text("(\(Int(size.width)), \(Int(size.height)))")
                    Text("aspect ratio: \(String(format: "%.02f", ratio))")
                }
            }
            .getSize { size in
                print(size)
                
                // although it works fine in Xcode preview,
                // it seems this line never runs in the built app.
                // (aspect ratio is updated in preview, but not in the built app)
                ratio = size.width / size.height
                
                // not even a single line in the console when run in the app.
                print(ratio)
            }
            .frame(width: size.width, height: size.height)
            
    }
    var controls: some View {
        VStack {
            Slider(value: $size.width, in: 50...300, step: 1)
            Slider(value: $size.height, in: 50...300, step: 1)
        }
        .padding(40)
    }
}

Now the code above behaves differently in the Xcoe preview and the built app:

ratio not updated in the app

My question is why the built app is not updating the "ratio" part in the UI?

original long story below:

I was doing some custom layout for an array of items, and used GeometryReader to read the proposed size from parent and then tried to update some view states based on that size.

It worked perfectly fine in the Xcode preview, but failed to update (some) view states in the built app, as you can see in the following GIF:

problem with .getSize()?

The following code is used in the preview:

struct ItemsView_Previews: PreviewProvider {
    static var previews: some View {
        ItemsView()
            .preferredColorScheme(.dark)
    }
}

and the following is for the app's content view:

struct ContentView: View {
    var body: some View {
        ItemsView()
            .overlay {
                Text("Built App")
                    .font(.largeTitle)
                    .bold()
                    .foregroundColor(.orange)
                    .opacity(0.3)
                    .shadow(radius: 2)
            }
    }
}

as you can see, they both use exactly the same ItemsView, which is defined by the following code:

import SwiftUI

struct ItemsView: View {
    
    @State private var size = CGSize(300, 300)   // proposed size
    
    @State private var rows = 0                  // current # of rows
    @State private var cols = 0                  // current # of cols
    @State private var ratio: CGFloat = 1        // current cell aspect ratio
    @State private var idealRatio: CGFloat = 1   // ideal cell aspect ratio
    
    let items = Array(1...20)
    
    var body: some View {
        VStack {
            ScrollView {
                itemsView   // view for layed out items
            }
            controls        // control size, cell ratio
        }
        .padding()
    }
}

extension ItemsView {
    
    /// a view with layed out item views
    var itemsView: some View {
        // layout item views
        items.itemsView { size in               // Array+ .itemsView()
            // ⭐ inject layout instance
            RatioRetainingLayout(               // RatioRetainingLayout
                idealRatio,
                count: items.count,
                in: size
            )
        } itemView: { i in
            // ⭐ inject view builder
            Color(hue: 0.8, saturation: 0.8, brightness: 0.5)
                .padding(1)
                .overlay {
                    Text("\(i)").shadow(radius: 2)
                }
        }
        // ⭐ get proposed size from parent
        .getSize { proposedSize in              // View+ .getSize()
            // ⭐ recompute layout
            let layout = RatioRetainingLayout(  //  RatioRetainingLayout
                idealRatio,
                count: items.count,
                in: proposedSize
            )
            // ⭐ update view states
            rows = layout.rows
            cols = layout.cols
            ratio = layout.cellSize.aspectRatio // ️ Vector2D: CGSize+ .aspectRatio
        }
        // ⭐ proposed size
        .frame(size)                            // View+ .frame(size), .dimension()
        .dimension(.topLeading, arrow: .blue, label: .orange)
        .padding(4)
        .shadowedBorder()                       // View+ .shadowedBorder()
        .padding(40)
    }
    
    /// sliders to control proposed size, ideal ratio
    var controls: some View {
        SizeRatioControl(                       //  SizeRatioControl
            size: $size,
            rows: $rows,
            cols: $cols,
            idealRatio: $idealRatio,
            ratio: $ratio
        )
    }
}

I used some custom extensions, protocols and types to support the ItemsView struct, but I think they are not relevant, if you're interested, you can have a look at GitHub.

I think the most relevant part in the above code is the following, where it tries to update some view states with respect to the proposed size:

        // ⭐ get proposed size from parent
        .getSize { proposedSize in              // View+ .getSize()
            // ⭐ recompute layout
            let layout = RatioRetainingLayout(  //  RatioRetainingLayout
                idealRatio,
                count: items.count,
                in: proposedSize
            )
            // ⭐ update view states
            rows = layout.rows
            cols = layout.cols
            ratio = layout.cellSize.aspectRatio // ️ Vector2D: CGSize+ .aspectRatio
        }

and the .getSize() part is a custom View extension which I used to get the proposed size from parent by using GeometryReader:

extension View {
    /// get view's size and do something with it.
    func getSize(action: @escaping (CGSize) -> Void) -> some View {
        background(GeometryReader{ geo in
            emptyView(size: geo.size, action: action)
        })
    }
    
    // private empty view
    private func emptyView(
        size  : CGSize, 
        action: @escaping (CGSize) -> Void
    ) -> some View {
        action(size)        // ⭐️ side effect❗️
        return EmptyView()
    }
}

While everything works fine in the Xcode preview, sadly it just doesn't work in the built app.

Am I doing something wrong with the SwiftUI view states? Please help. Thanks.

lochiwei
  • 1,240
  • 9
  • 16
  • Needed minimal reproducible standalone example – Asperi Feb 19 '22 at 09:10
  • @Asperi I've made a much shorter example, please give me some advice, thanks. – lochiwei Feb 19 '22 at 12:37
  • @Asperi I just found out that I asked a [similar question](https://stackoverflow.com/a/64311586/5409815) about a year and a half ago, and you already gave me some advice back then. Do you still recommend using the `DispatchQueue.main.async` trick? – lochiwei Feb 19 '22 at 13:06

1 Answers1

0

I finally come to realize that I've been keeping violating the most important rule in SwiftUI - the "source of truth" rule.

I shouldn't have made the ratio a @State private var in the first place, its value totally depends on size, and that means ratio should be a computed property instead.

So, with the following revision, everything works just fine:

(we don't even need the orginal .getSize() extension)

struct MyView: View {
    
    // ⭐️ source of truth
    @State private var size = CGSize(width: 300, height: 200)
    
    // ⭐️ computed property
    var ratio: CGFloat { size.width / size.height }
    
    var body: some View {
        VStack {
            Spacer()
            cell
            Spacer()
            controls
        }
    }

    var cell: some View {
        Color.pink
            .overlay {
                VStack {
                    Text("(\(Int(size.width)), \(Int(size.height)))")
                    Text("aspect ratio: \(String(format: "%.02f", ratio))")
                }
            }
            .frame(width: size.width, height: size.height)
            
    }

    var controls: some View {
        VStack {
            Slider(value: $size.width, in: 50...300, step: 1)
            Slider(value: $size.height, in: 50...300, step: 1)
        }
        .padding(40)
    }
}
lochiwei
  • 1,240
  • 9
  • 16