-
-
Save wildthink/b64b9767a1364277cf8c10a4aafa3d1f to your computer and use it in GitHub Desktop.
Code to capture frames of views for use elsewhere in the SwiftUI hierarchy
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment