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:
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:
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.