|
|
@@ -0,0 +1,352 @@ |
|
|
// |
|
|
// FrameCaptureModifier.swift |
|
|
// FrameCaptureModifier |
|
|
// |
|
|
// Created by Marc Palmer on 31/03/2020. |
|
|
// |
|
|
// This is free and unencumbered software released into the public domain. |
|
|
// |
|
|
// Anyone is free to copy, modify, publish, use, compile, sell, or |
|
|
// distribute this software, either in source code form or as a compiled |
|
|
// binary, for any purpose, commercial or non-commercial, and by any |
|
|
// means. |
|
|
// |
|
|
// In jurisdictions that recognize copyright laws, the author or authors |
|
|
// of this software dedicate any and all copyright interest in the |
|
|
// software to the public domain. We make this dedication for the benefit |
|
|
// of the public at large and to the detriment of our heirs and |
|
|
// successors. We intend this dedication to be an overt act of |
|
|
// relinquishment in perpetuity of all present and future rights to this |
|
|
// software under copyright law. |
|
|
// |
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|
|
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|
|
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. |
|
|
// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR |
|
|
// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, |
|
|
// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR |
|
|
// OTHER DEALINGS IN THE SOFTWARE. |
|
|
// |
|
|
// For more information, please refer to <https://unlicense.org> |
|
|
// |
|
|
import Foundation |
|
|
import SwiftUI |
|
|
|
|
|
// This code is a convenience for capturing and storing the geometry of SwiftUI views without having to deal |
|
|
// with all the pain of view preferences. |
|
|
// |
|
|
// Usage: |
|
|
// |
|
|
// ``` |
|
|
// struct YourView: View { |
|
|
// @State var textFrame: CGRect = .zero |
|
|
// |
|
|
// var body: some View { |
|
|
// VStack { |
|
|
// Text("Hello") |
|
|
// .capturingFrame(id: "text", coordinateSpace: .global) |
|
|
// |
|
|
// Text("Width is \(textFrame.width)") |
|
|
// } |
|
|
// .storeFrame(of: "text", in: $textFrame) |
|
|
// } |
|
|
// } |
|
|
// ``` |
|
|
// The above will store and update the frame rect in global coords in the state. |
|
|
// |
|
|
// |
|
|
// ``` |
|
|
// struct YourView: View { |
|
|
// @State var frames: [String:ViewFrameData] = [:] |
|
|
// |
|
|
// var body: some View { |
|
|
// VStack { |
|
|
// Text("Hello") |
|
|
// .capturingFrame(id: "text", coordinateSpace: .global) |
|
|
// |
|
|
// Text("Width is \(frames["text"]?.width ?? 0)") |
|
|
// } |
|
|
// .storingFrames(in: frames) |
|
|
// } |
|
|
// } |
|
|
// ``` |
|
|
// The above will store and update multiple frames by ID. |
|
|
// |
|
|
|
|
|
|
|
|
/// The frame of a specific View |
|
|
struct ViewFrameData: Equatable { |
|
|
let identifier: String |
|
|
let frame: CGRect |
|
|
} |
|
|
|
|
|
/// The preference to store the frame of a single View |
|
|
struct CapturedFramesKey: PreferenceKey { |
|
|
typealias Value = [String:ViewFrameData] |
|
|
|
|
|
static var defaultValue: [String:ViewFrameData] = [:] |
|
|
static let lock = NSRecursiveLock() |
|
|
|
|
|
static func reduce(value: inout [String:ViewFrameData], nextValue: () -> [String:ViewFrameData]) { |
|
|
value.merge(nextValue(), uniquingKeysWith: { current, new in new }) |
|
|
} |
|
|
} |
|
|
|
|
|
/// A view modifier that captures the geometry of the View in a preference, for storage by the storage modifier. |
|
|
struct FrameCaptureModifier: ViewModifier { |
|
|
let identifier: String |
|
|
let coordinateSpace: CoordinateSpace |
|
|
|
|
|
func body(content: Content) -> some View { |
|
|
content.background( |
|
|
GeometryReader { geometry in |
|
|
Color.clear |
|
|
.preference(key: CapturedFramesKey.self, |
|
|
value: [self.identifier: ViewFrameData(identifier: self.identifier, |
|
|
frame: geometry.frame(in: self.coordinateSpace))]) |
|
|
} |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
/// A view modifier that stores the captured geometry of views in a binding, keyed on view ID |
|
|
struct FrameDirectStoreModifier: ViewModifier { |
|
|
enum AnimationBehaviour { |
|
|
case automatic |
|
|
case explicit(_ animation: Animation?) |
|
|
} |
|
|
|
|
|
let frameData: Binding<[String:ViewFrameData]> |
|
|
let animation: AnimationBehaviour |
|
|
|
|
|
init(frameData: Binding<[String:ViewFrameData]>) { |
|
|
self.animation = .automatic |
|
|
self.frameData = frameData |
|
|
} |
|
|
|
|
|
init(frameData: Binding<[String:ViewFrameData]>, animation: Animation?) { |
|
|
self.animation = .explicit(animation) |
|
|
self.frameData = frameData |
|
|
} |
|
|
|
|
|
func body(content: Content) -> some View { |
|
|
content |
|
|
.onPreferenceChange(CapturedFramesKey.self) { value in |
|
|
switch animation { |
|
|
case .automatic: |
|
|
withAnimation { |
|
|
frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b }) |
|
|
} |
|
|
case .explicit(let animation): |
|
|
if let animation = animation { |
|
|
withAnimation(animation) { |
|
|
frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b }) |
|
|
} |
|
|
} else { |
|
|
frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b }) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
/// A view modifier that stores the captured geometry of a single view in a binding |
|
|
fileprivate struct FrameBindingAssignment: ViewModifier { |
|
|
let binding: Binding<CGRect> |
|
|
let animation: Animation? |
|
|
let identifier: String |
|
|
|
|
|
init(identifier: String, binding: Binding<CGRect>, animation: Animation? = nil) { |
|
|
self.identifier = identifier |
|
|
self.animation = animation |
|
|
self.binding = binding |
|
|
} |
|
|
|
|
|
func body(content: Content) -> some View { |
|
|
content.modifier( |
|
|
FrameAssignmentModifier(identifier: identifier, |
|
|
frameSetHandler: { binding.wrappedValue = $0 }, |
|
|
animation: animation) |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
/// A view modifier that stores the maximum (union) captured geometry of views in a binding as a single CGRect |
|
|
fileprivate struct FrameMaxAssignmentModifier: ViewModifier { |
|
|
enum AnimationBehaviour { |
|
|
case automatic |
|
|
case explicit(_ animation: Animation?) |
|
|
} |
|
|
|
|
|
let frameStore: Binding<CGRect> |
|
|
let animation: AnimationBehaviour |
|
|
|
|
|
init(frameStore: Binding<CGRect>) { |
|
|
self.animation = .automatic |
|
|
self.frameStore = frameStore |
|
|
} |
|
|
|
|
|
init(frameStore: Binding<CGRect>, animation: Animation?) { |
|
|
self.animation = .explicit(animation) |
|
|
self.frameStore = frameStore |
|
|
} |
|
|
|
|
|
func body(content: Content) -> some View { |
|
|
content |
|
|
.onPreferenceChange(CapturedFramesKey.self) { value in |
|
|
let maxFrame = value.values.reduce(CGRect.zero) { result, value in |
|
|
return result.union(value.frame) |
|
|
} |
|
|
switch animation { |
|
|
case .automatic: |
|
|
withAnimation { |
|
|
frameStore.wrappedValue = maxFrame |
|
|
} |
|
|
case .explicit(let animation): |
|
|
if let animation = animation { |
|
|
withAnimation(animation) { |
|
|
frameStore.wrappedValue = maxFrame |
|
|
} |
|
|
} else { |
|
|
frameStore.wrappedValue = maxFrame |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
/// A view modifier that stores the captured geometry of a single view in a binding |
|
|
fileprivate struct FrameAssignmentModifier: ViewModifier { |
|
|
enum AnimationBehaviour { |
|
|
case automatic |
|
|
case explicit(_ animation: Animation?) |
|
|
} |
|
|
|
|
|
let frameSetHandler: (CGRect) -> Void |
|
|
let animation: AnimationBehaviour |
|
|
let identifier: String |
|
|
|
|
|
init(identifier: String, frameSetHandler: @escaping (CGRect) -> Void) { |
|
|
self.identifier = identifier |
|
|
self.animation = .automatic |
|
|
self.frameSetHandler = frameSetHandler |
|
|
} |
|
|
|
|
|
init(identifier: String, frameSetHandler: @escaping (CGRect) -> Void, animation: Animation?) { |
|
|
self.identifier = identifier |
|
|
self.animation = .explicit(animation) |
|
|
self.frameSetHandler = frameSetHandler |
|
|
} |
|
|
|
|
|
func body(content: Content) -> some View { |
|
|
content // If I return just this it works, but if I call onPreferenceChange it crashes |
|
|
.onPreferenceChange(CapturedFramesKey.self) { viewFrameData in |
|
|
guard let data = viewFrameData[self.identifier] else { |
|
|
return |
|
|
} |
|
|
switch self.animation { |
|
|
case .automatic: |
|
|
withAnimation { |
|
|
frameSetHandler(data.frame) |
|
|
} |
|
|
case .explicit(let animation): |
|
|
if let animation = animation { |
|
|
withAnimation(animation) { |
|
|
frameSetHandler(data.frame) |
|
|
} |
|
|
} else { |
|
|
frameSetHandler(data.frame) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
/// Convenience functions for the modifiers |
|
|
extension View { |
|
|
/// Set up capturing the frame of a view, using the given ID to store the frame. |
|
|
/// |
|
|
/// - note: The frame will only be stored if you have a view that contains this view with one of the `storeFrame(s)` |
|
|
/// modifiers on it to tell it where to store the information. |
|
|
func capturingFrame(id identifier: String, coordinateSpace: CoordinateSpace = .global) -> some View { |
|
|
modifier(FrameCaptureModifier(identifier: identifier, coordinateSpace: coordinateSpace)) |
|
|
} |
|
|
|
|
|
/// Store the frames of all descendent views in the supplied dictionary binding. They are keyed on their capture ID. |
|
|
/// Animation is automatic. |
|
|
func storeFrames(in frameData: Binding<[String:ViewFrameData]>) -> some View { |
|
|
modifier(FrameDirectStoreModifier(frameData: frameData)) |
|
|
} |
|
|
|
|
|
/// Store the frames of all descendent views in the supplied dictionary binding, updating the binding using the supplied animation. |
|
|
/// They are keyed on their capture ID. Animation can be specified, nil means none. |
|
|
func storeFrames(in frameData: Binding<[String:ViewFrameData]>, animation: Animation?) -> some View { |
|
|
modifier(FrameDirectStoreModifier(frameData: frameData, animation: animation)) |
|
|
} |
|
|
|
|
|
/// Store the frame of a single descendent view in the supplied CGRect binding. The view must have a `captureFrame` modifier |
|
|
/// that specifies the same ID passed in here. Animation is automatic. |
|
|
func storeFrame(of identifier: String, in binding: Binding<CGRect>) -> some View { |
|
|
modifier(FrameBindingAssignment(identifier: identifier, binding: binding)) |
|
|
} |
|
|
|
|
|
/// Store the frame of a single descendent view in the supplied CGRect binding. The view must have a `captureFrame` modifier |
|
|
/// that specifies the same ID passed in here. Animation can be specified, nil means none. |
|
|
func storeFrame(of identifier: String, in binding: Binding<CGRect>, animation: Animation?) -> some View { |
|
|
modifier(FrameBindingAssignment(identifier: identifier, binding: binding, animation: animation)) |
|
|
} |
|
|
|
|
|
/// Store the maximum (union) frame of all views that use `capturingFrame` below this modifier in the binding. |
|
|
/// Animation is automatic. |
|
|
func storeMaxFrame(in frame: Binding<CGRect>) -> some View { |
|
|
modifier(FrameMaxAssignmentModifier(frameStore: frame)) |
|
|
} |
|
|
|
|
|
/// Store the maximum (union) frame of all views that use `capturingFrame` below this modifier in the binding. |
|
|
/// Animation can be specified, nil means no animation |
|
|
func storeMaxFrame(in frame: Binding<CGRect>, animation: Animation?) -> some View { |
|
|
modifier(FrameMaxAssignmentModifier(frameStore: frame, animation: animation)) |
|
|
} |
|
|
|
|
|
/// Call the closure when the view captured with the specified identifier receives a frame change. |
|
|
/// The view **must** use `.capturingFrame(id:,coordinateSpace)` to emit its frame for this to be able to receive it. |
|
|
func onFrameChange(of identifier: String, perform block: @escaping (CGRect) -> Void) -> some View { |
|
|
modifier(FrameAssignmentModifier(identifier: identifier, frameSetHandler: block, animation: nil)) |
|
|
} |
|
|
|
|
|
/// Call the closure when the view receives a frame change. This does not require calling `capturingFrame()` |
|
|
/// because it does it for you. |
|
|
/// |
|
|
/// The `id` is required to deduplicate preferences keys used internally so you MUST choose an ID unique |
|
|
/// to your view tree. |
|
|
func onFrameChange(id identifier: String, coordinateSpace: CoordinateSpace = .global, perform block: @escaping (CGRect) -> Void) -> some View { |
|
|
return self |
|
|
.capturingFrame(id: identifier, coordinateSpace: coordinateSpace) |
|
|
.onFrameChange(of: identifier, perform: block) |
|
|
} |
|
|
} |
|
|
|
|
|
struct FrameCapture_Previews: PreviewProvider { |
|
|
struct Harness: View { |
|
|
@State var buttonFrame: CGRect = .null |
|
|
|
|
|
var body: some View { |
|
|
VStack { |
|
|
if #available(iOS 15, *) { |
|
|
Button(action: { }) { |
|
|
Text("Button") |
|
|
} |
|
|
.buttonStyle(.borderedProminent) |
|
|
.onFrameChange(id: "Button1") { rect in |
|
|
buttonFrame = rect |
|
|
} |
|
|
|
|
|
Text("Frame is: \(buttonFrame.origin.x), \(buttonFrame.origin.y), \(buttonFrame.size.width), \(buttonFrame.size.height))") |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
static var previews: some View { |
|
|
Harness() |
|
|
} |
|
|
} |