Skip to content

Instantly share code, notes, and snippets.

@wildthink
Forked from marcpalmer/FrameCaptureModifier.swift
Created November 26, 2022 15:32
Show Gist options
  • Save wildthink/b64b9767a1364277cf8c10a4aafa3d1f to your computer and use it in GitHub Desktop.
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
//
// 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