onPreferenceChange not called on geometryReader frame change - swiftui

PreferenceKey definition
struct ScrollPrefKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Main Code
ScrollView {
GeometryReader { proxy in
Text("\(proxy.frame(in: .named("scroll")).minY)") // I can see changes
Color.clear.preference(key: ScrollPrefKey.self, value: proxy.frame(in: .named("scroll")).minY)
} //MARK: END GeometryReader
VStack {
//main content
Button(action: {}, label: {Text("myButton")})
}
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollPrefKey.self, perform: { value in
print(value)
//DO SOME THINGS - but it never trigger
})
This code works in Preview but not using simulator (iOS 16.1)
Replacing VStack with a static Color with defined height works
Embedding the entire content of scrollView including GeometryReader still doesn't work

static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)
nextValue: () -> Self.Value : This function allows for logic to reduce all those preferences to a single value, if multiple values are outputted by multiple different views, all using the same PreferenceKey.
When the view loads first time, the preference key will be updated from defaultValue to initial value. And since we have only one view producing the value with one preference key, the nextValue() returns nil in this case.
value: inout Self.Value:
Though the value, which is keep accumulating through previous calls and keeps updating with the preference key value changes. So we should use the value here.
Update the PreferenceKey definition in the code with this:
struct ScrollPrefKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}

Related

SwiftUI ScrollView ScrollTo Offset (NOT ID)

Is it possible to SET ScrollView offset in SwiftUI?
I have made a custom tab bar that uses a Switch/Case to change views. However my views all contain vertical ScrollViews. I understand that each time I switch between Views they are destroyed, and thus the scrollView offset is lost.
I have used the following approach to GET ScrollView Offset, however I am now not sure how I can use this information. I have seen there is now ScrollTo but this seems to only work with an ID.
Is it possible to use ScrollTo with an Offset in some way?
In general, what I'm trying to achieve is standard Tab bar behaviour where a user returns to the same position they left each Tab
Any help is appreciated. Also, please let me know if this is bad for performance as I am a novice. Thanks.
private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}
struct ScrollViewWithOffset<T: View>: View {
let axes: Axis.Set
let showsIndicator: Bool
let offsetChanged: (CGPoint) -> Void
let content: T
init(axes: Axis.Set = .vertical,
showsIndicator: Bool = false,
offsetChanged: #escaping (CGPoint) -> Void = { _ in },
#ViewBuilder content: () -> T
) {
self.axes = axes
self.showsIndicator = showsIndicator
self.offsetChanged = offsetChanged
self.content = content()
}
var body: some View {
ScrollView(axes, showsIndicators: showsIndicator) {
GeometryReader { proxy in
Color.clear.preference(
key: ScrollViewOffsetPreferenceKey.self,
value: proxy.frame(in: .named("scrollView")).origin
)
}
.frame(width: 0, height: 0)
content
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self, perform: offsetChanged)
}
}
Used like so...
ScrollViewWithOffset { point in
scrollViewOffset = point.y
} content: {
ScrollViewReader { proxy in
LazyVStack(spacing: 4) {
ForEach(0..<10, id: \.self) { i in
Item()
.id(i)
}
}
}
}

How to make custom .sheet viewmodifier

I am trying to recreate the native .sheet() view modifier in SwiftUI. When I look at the definition, I get below function, but I'm not sure where to go from there.
The .sheet somehow passes a view WITH bindings to a distant parent at the top of the view-tree, but I can't see how that is done. If you use PreferenceKey with an AnyView, you can't have bindings.
My usecase is that I want to define a sheet in a subview, but I want to activate it at a distant parent-view to avoid it interfering with other code.
func showSheet<Content>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> Content) -> some View where Content : View {
// What do I put here?
}
So, I ended up doing my own sheet in SwiftUI using a preferenceKey for passing the view up the view-tree, and an environmentObject for passing the binding for showing/hiding the sheet back down again.
It's a bit long-winded, but here's the gist of it:
struct HomeOverlays<Content: View>: View {
#Binding var showSheet:Bool
#State private var sheet:EquatableViewContainer = EquatableViewContainer(id: "original", view: AnyView(Text("No view")))
#State private var animatedSheet:Bool = false
#State private var dragPercentage:Double = 0 /// 1 = fully visible, 0 = fully hidden
// Content
let content: Content
init(_ showSheet: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._showSheet = showSheet
self.content = content()
}
var body: some View {
GeometryReader { geometry in
ZStack {
content
.blur(radius: 5 * dragPercentage)
.opacity(1 - dragPercentage * 0.5)
.disabled(showSheet)
.scaleEffect(1 - 0.1 * dragPercentage)
.frame(width: geometry.size.width, height: geometry.size.height)
if animatedSheet {
sheet.view
.background(Color.greyB.opacity(0.5).edgesIgnoringSafeArea(.bottom))
.cornerRadius(5)
.transition(.move(edge: .bottom).combined(with: .opacity))
.dragToSnap(snapPercentage: 0.3, dragPercentage: $dragPercentage) { showSheet = false } /// Custom modifier for measuring how far the view is dragged down. If more than 30% it snaps showSheet to false, and otherwise it snaps it back up again
.edgesIgnoringSafeArea(.bottom)
}
}
.onPreferenceChange(HomeOverlaySheet.self, perform: { value in self.sheet = value } )
.onChange(of: showSheet) { show in sheetUpdate(show) }
}
}
func sheetUpdate(_ show:Bool) {
withAnimation(.easeOut(duration: 0.2)) {
self.animatedSheet = show
if show { dragPercentage = 1 } else { dragPercentage = 0 }
}
// Delay onDismiss action if removing sheet, so animation can complete
if show == false {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
sheet.action()
}
}
}
}
struct HomeOverlays_Previews: PreviewProvider {
static var previews: some View {
HomeOverlays(.constant(false)) {
Text("Home overlays")
}
}
}
// MARK: Preference key for passing view up the tree
struct HomeOverlaySheet: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(id: "default", view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
if value != nextValue() && nextValue().id != "default" {
value = nextValue()
}
}
}
// MARK: View extension for defining view somewhere in view tree
extension View {
// Change only leading view
func homeSheet<SheetView: View>(onDismiss action: #escaping () -> Void, #ViewBuilder sheet: #escaping () -> SheetView) -> some View {
let sheet = sheet()
return
self
.preference(key: HomeOverlaySheet.self, value: EquatableViewContainer(view: AnyView( sheet ), action: action ))
}
}

How can make a function which take View and returns custom result in SwiftUI?

I want build a function stand alone from ContentView which I could use this func to initialize some value, for example in this down code I want get size of View with the function, but for unknown reason for me it returns zero, I think the background modification do not work as I wanted in this build. any help?
func viewSizeReaderFunction<Content: View>(content: Content) -> CGSize {
var sizeOfView: CGSize = CGSize()
content
.background(
GeometryReader { geometry in
Color
.clear
.onAppear() { sizeOfView = geometry.size }
})
return sizeOfView
}
let sizeOfText: CGSize = viewSizeReaderFunction(content: Text("Hello, world!"))
struct ContentView: View {
var body: some View {
Color.red
.onAppear() {
print(sizeOfText)
}
}
}
The general idea is to have the view report its size using preference, and create a view modifier to capture that. But, like #RobNapier said, the struct has to be in the view hierarchy, and so within a rendering context, to be able to talk about sizes.
struct SizeReporter: ViewModifier {
#Binding var size: CGSize
func body(content: Content) -> some View {
content
.background(GeometryReader { geo in
Color.clear
.preference(key: SizePreferenceKey.self, value: geo.size)
})
.onPreferenceChange(SizePreferenceKey.self, perform: { value in
size = value
})
}
}
And we'd need to define SizePreferenceKey:
extension SizeReporter {
private struct SizePreferenceKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
}
You could create a convenience method on View:
extension View {
func getSize(_ size: Binding<CGSize>) -> some View {
self.modifier(SizeReporter(size: size))
}
}
and use it like so:
#State var size: CGSize = .zero
var body: some View {
Text("hello").getSize($size)
}
Views are just data that describe view layout. They are not objects that represent the actual "view" in the way that UIView is. They do not have their own logic or state (which is why they require #State variables rather than just var).
The code you've written assigns a zero-size to sizeOfView, then creates a View struct that is immediately thrown away, and then returns sizeOfView. There is nothing that evaluates the View struct. I would expect that the compiler is giving you a warning about this, something like "result of call to background is unused."
The way you do what you're describing is with Preferences, .onPreferenceChange and usually #State. There are a lot of answers to that around Stack Overflow.
https://stackoverflow.com/search?q=%5Bswiftui%5D+size+of+view
Here's one example, note in particular the use of .hidden():
extension View {
func saveSize(handler: #escaping (CGSize) -> Void) -> some View {
return self
.background(
GeometryReader { geometry in
Color.clear
.onAppear {
handler(geometry.size)
}
})
}
}
struct ContentView: View {
#State var sizeOfText: CGSize = .zero
var body: some View {
ZStack {
Color.red
.onAppear() {
print(sizeOfText)
}
Text("Hello, world!")
.hidden()
.saveSize { sizeOfText = $0 }
}
}
}
Note that this code is slightly dangerous in that it relies on the order that onAppear gets called, and that's not promised. In practice, you generally need to handle the case where the size hasn't been set yet. This can be made more robust with Preferences, but that tends to be a lot of hassle.

Add a preference key to NSViewPepresentable/UIViewRepresentable in SwiftUI

I have an instance of a custom AppKit/UIKit view, wrapped into an **ViewRepresentable that needs to communicate some diagnostic information up the SwiftUI hierarchy. Since this information is not of primary importance, I would like to use the PreferenceKey API to do it, so that parent views can subscribe to it only when needed. Unfortunately, I can't figure a way to set a preference key on my view, since I don't have assess to the view struct itself and thus can't use the preference() API. Is there some way how I can set the preference key from the coordinator?
Here is a simple demo of possible approach. Tested with Xcode 12 / iOS 14. The idea is to construct UIViewRepresentable with injected preference key.
struct DemoView: View {
#State private var diag = CGFloat.zero
var body: some View {
VStack {
Text("Diagnostic >> \(diag)")
.background(MyUIRep.trackingRep)
}
.onPreferenceChange(MyPrefKey.self) { value in
self.diag = value
}
}
}
struct TestPreferenceKeyInRep_Previews: PreviewProvider {
static var previews: some View {
TestPreferenceKeyInRep()
}
}
struct MyUIRep: UIViewRepresentable {
static var trackingRep: some View {
MyUIRep().preference(key: MyPrefKey.self, value: 13.31)
}
func makeUIView(context: Context) -> UIView {
return UIView()
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
struct MyPrefKey : PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}

Measure the rendered size of a SwiftUI view?

Is there a way to measure the computed size of a view after SwiftUI runs its view rendering phase? For example, given the following view:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
}
}
With the view selected, the computed size is displayed In the preview canvas in the bottom left corner. Does anyone know of a way to get access to that size in code?
Printing out values is good, but being able to use them inside the parent view (or elsewhere) is better. So I took one more step to elaborate it.
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
DispatchQueue.main.async { // avoids warning: 'Modifying state during view update.' Doesn't look very reliable, but works.
self.rect = g.frame(in: .global)
}
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
#State private var rect1: CGRect = CGRect()
var body: some View {
HStack {
// make to texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").background(Color.blue).background(GeometryGetter(rect: $rect1))
// You can then use rect in other places of your view:
Text("text").frame(width: rect1.width, height: rect1.height).background(Color.green)
Text("text").background(Color.yellow)
}
}
}
You could add an "overlay" using a GeometryReader to see the values. But in practice it would probably be better to use a "background" modifier and handle the sizing value discretely
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(
GeometryReader { proxy in
Text("\(proxy.size.width) x \(proxy.size.height)")
}
)
}
}
Here is the ugly way I came up with to achieve this:
struct GeometryPrintingView: View {
var body: some View {
GeometryReader { geometry in
return self.makeViewAndPrint(geometry: geometry)
}
}
func makeViewAndPrint(geometry: GeometryProxy) -> Text {
print(geometry.size)
return Text("")
}
}
And updated Foo version:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(GeometryPrintingView())
}
}
To anyone who wants to obtain a size out of Jack's solution and store it in some property for further use:
.overlay(
GeometryReader { proxy in
Text(String())
.onAppear() {
// Property, eg
// #State private var viewSizeProperty = CGSize.zero
viewSizeProperty = proxy.size
}
.opacity(.zero)
}
)
This is a bit dirty obviously, but why not if it works.
Try to use PreferenceKey like this.
struct HeightPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct WidthPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct SizePreferenceKey : PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func readWidth() -> some View {
background(GeometryReader {
Color.clear.preference(key: WidthPreferenceKey.self, value: $0.size.width)
})
}
func readHeight() -> some View {
background(GeometryReader {
Color.clear.preference(key: HeightPreferenceKey.self, value: $0.size.height)
})
}
func onWidthChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(WidthPreferenceKey.self) { width in
action(width)
}
}
func onHeightChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(HeightPreferenceKey.self) { height in
action(height)
}
}
func readSize() -> some View {
background(GeometryReader {
Color.clear.preference(key: SizePreferenceKey.self, value: $0.size)
})
}
func onSizeChange(perform action: #escaping (CGSize) -> Void) -> some View {
onPreferenceChange(SizePreferenceKey.self) { size in
action(size)
}
}
}
struct MyView: View {
#State private var height: CGFloat = 0
var body: some View {
...
.readHeight()
.onHeightChange {
height = $0
}
}
}
As others pointed out, GeometryReader and a custom PreferenceKey is the best way forward for now. I've implemented a helper drop-in library which does pretty much that: https://github.com/srgtuszy/measure-size-swiftui