How do I access the current value of this animatable property scaleAmount?
struct ContentView: View {
#State private var scaleAmount: CGFloat = 1
var body: some View {
VStack {
Stepper("Scale amount", value: $scaleAmount.animation(), in: 1...5)
Circle()
.fill(Color.red)
.frame(width: 50, height: 50)
.scaleEffect(scaleAmount)
.onChange(of: scaleAmount) { value in
print(value)
}
}
}
}
Output:
2.0
3.0
4.0
5.0
These are the final values of the animatable property - not the interpolated values used for the animation. Those are what I need.
Is it possible to do this? I have also tried replacing the .onChange block to
.onChange(of: $scaleAmount.animation()) { value in
print(value)
}
But the compiler says that the Binding returned by the .animation() call must conform to Equatable.
I have also tried factoring out the Circle view into a custom view and only giving it a CGFloat to work with, and the animation happens. But the view drawing the circle has no binding in it. This gives the same output as the first code snippet.
struct ContentView: View {
#State private var scaleAmount: CGFloat = 1
var body: some View {
VStack {
Stepper("Scale amount", value: $scaleAmount.animation(), in: 1...5)
MyCircle(scaleAmount: scaleAmount)
}
}
}
struct MyCircle: View {
var scaleAmount: CGFloat
var body: some View {
print(scaleAmount)
return Circle()
.fill(Color.red)
.frame(width: 50, height: 50)
.scaleEffect(scaleAmount)
}
}
So, the question again: Is it possible to get the current value of scaleAmount property as it is being animated?
//
// Created by Eric Lightfoot on 2021-06-24.
//
import SwiftUI
/// Use .onBody(observedValue:onBody:) by passing an
/// animatable property along with a closure to execute
/// whenever that property is changed throughout the
/// animation
/// ---
// struct ContentView: View {
// #State private var scaleAmount: CGFloat = 1
// #State var scaleText: String = "1.000"
//
// var body: some View {
// VStack {
// Stepper("Scale amount", value: $scaleAmount.animation(), in: 1...5)
// ZStack {
// Circle()
// .fill(Color.red)
// .frame(width: 50, height: 50)
// Text(scaleText)
// .onBody(for: scaleAmount, onBody: { scaleText = "\($0)" })
// }
// .scaleEffect(scaleAmount)
// }
// }
// }
/// ---
struct AnimationProgressObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
/// Some experimenting led to observing the behaviour
/// that wrapping the observedValue with animatableData
/// (to conform to the protocol) caused this property
/// to be set continuously during the animation, instead
/// of only at the end
var animatableData: Value {
get {
observedValue
}
set {
observedValue = newValue
runCallbacks()
}
}
/// The animatable property to be observed
/// passed in at init
private var observedValue: Value
/// The block to execute on each animation progress
/// update
private var onBody: (Value) -> Void
init (observedValue: Value, onBody: #escaping (Value) -> Void) {
self.onBody = onBody
self.observedValue = observedValue
}
/// Run the onBody block provided at init
/// specifically passing animatable data,
/// NOT observedValue
private func runCallbacks () {
DispatchQueue.main.async {
onBody(animatableData)
}
}
func body (content: Content) -> some View {
/// No modification to view content
content
}
}
extension View {
/// Convenience method on View
func onBody<Value: VectorArithmetic> (for value: Value, onBody: #escaping (Value) -> Void)
-> ModifiedContent<Self, AnimationProgressObserverModifier<Value>> {
modifier(AnimationProgressObserverModifier(observedValue: value, onBody: onBody))
}
}
Related
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.
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)
}
}
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 ))
}
}
I have a model object, which has a published property displayMode, which is updated asynchronously via events from the server.
class RoomState: NSObject, ObservableObject {
public enum DisplayMode: Int {
case modeA = 0
case modeB = 1
case modeC = 2
}
#Published var displayMode = DisplayMode.modeA
func processEventFromServer(newValue: DisplayMode) {
DispatchQueue.main.async {
self.displayMode = newValue
}
}
}
Then, I have a View, which displays this mode by placing some image in a certain location depending on the value.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
}
}
}
This code works fine, but I want to animate the movement when the value changes. If I change the value in the code block inside the View, I can use withAnimation {..} to create an animation effect, but I am not able to figure out how to do it from the model.
This is the answer, thanks to #aheze. With .animation(), this Image view always animates when the state.displayMode changes.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
.animation(.easeInOut)
}
}
}
With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}
Yesterday I came across this post: What is GeometryReader in SwiftUI?, and I was curious about the behavior of GeometryReader, so I went on some experimentation, and the following is what I did after finished reading the post:
import SwiftUI
import PlaygroundSupport
/* ------- Approach 1 ------- */
struct ViewGettingSize: View {
#Binding var size: CGSize
func makeView(with geometry: GeometryProxy) -> some View {
// ⭐️ Try to update #Binding var `size`,
// but SwiftUI ignores this assignment, why?
// #Binding var `size` is NOT updated.
self.size = geometry.size
print(geometry.size) // (158.5, 45.5)
print(self.size) // (50, 50)
return Color.pink
}
var body: some View {
GeometryReader { geo in
self.makeView(with: geo) // Color.pink
}
}
}
/* ------- Approach 2 ------- */
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct ViewSettingSizePreference: View {
func makeView(with geometry: GeometryProxy) -> some View {
print(geometry.size) // (158.5, 45.5)
return Color.orange
.preference(key: SizePreferenceKey.self, value: geometry.size)
}
var body: some View {
GeometryReader { geo in
self.makeView(with: geo) // Color.orange
}
}
}
/* ------- Test These Approaches ------- */
let text = Text("some text").font(.largeTitle)
// live view
struct ContentView: View {
#State private var size = CGSize(50, 50)
#State private var size2 = CGSize(50, 50)
var body: some View {
VStack {
Group {
/* ------- Approach 1 ------- */
text
// ⭐️ this one doesn't work.
.background(ViewGettingSize(size: $size))
Color.blue
// ⭐️ `size` is still (50,50)
.frame(width: self.size.width, height: self.size.height)
/* ------- Approach 2 ------- */
text
// ⭐️ this one works.
.background(ViewSettingSizePreference())
.onPreferenceChange(SizePreferenceKey.self) { (size) in
print(size) // (158.5, 45.5)
self.size2 = size // ⭐️ `size2` updated successfully.
print(self.size2) // (158.5, 45.5)
}
Color.purple
.frame(width: self.size2.width, height: self.size2.height)
}// Group
.border(Color.black)
}// VStack (container)
.padding()
.background(Color.gray)
}
}
PlaygroundPage.current.setLiveView(ContentView())
Result:
From above, I used two approaches and tried to update the ContentView through updating its #State variables, although the second approach was successful, but the first one failed, does anyone know why it failed? Thanks.
// but SwiftUI ignores this assignment, why?
Because it would produce rendering cycle (changing state initiates refresh, which change state, and so on), and SwiftUI rendering engine is clever enough to drop such updates.
You need to delay such update for next event loop, like
func makeView(with geometry: GeometryProxy) -> some View {
DispatchQueue.main.async {
self.size = geometry.size
}
print(geometry.size) // (158.5, 45.5)
print(self.size) // (50, 50)
return Color.pink
}
See this approach correct usage for eg. in https://stackoverflow.com/a/60214735/12299030