Skip to content

Instantly share code, notes, and snippets.

@wildthink
Forked from marcpalmer/FrameCaptureModifier.swift
Created November 26, 2022 15:32
Show Gist options
  • Select an option

  • Save wildthink/b64b9767a1364277cf8c10a4aafa3d1f to your computer and use it in GitHub Desktop.

Select an option

Save wildthink/b64b9767a1364277cf8c10a4aafa3d1f to your computer and use it in GitHub Desktop.

Revisions

  1. @marcpalmer marcpalmer revised this gist Nov 17, 2022. 2 changed files with 352 additions and 140 deletions.
    140 changes: 0 additions & 140 deletions FrameCapture.swift
    Original file line number Diff line number Diff line change
    @@ -1,140 +0,0 @@
    //
    // FrameCapture.swift
    // Captionista
    //
    // Created by Marc Palmer on 31/03/2020.
    // Copyright © 2020 Montana Floss Co. Ltd. All rights reserved.
    //

    import Foundation
    import SwiftUI

    /// 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 CellFrameKey: PreferenceKey {
    typealias Value = [String:ViewFrameData]

    static var defaultValue: [String:ViewFrameData] = [:]

    static func reduce(value: inout [String:ViewFrameData], nextValue: () -> [String:ViewFrameData]) {
    value.merge(nextValue(), uniquingKeysWith: { a, b in b })
    }
    }

    /// A view modifier that captures the geometry of the View in a preference
    struct FrameCapture: ViewModifier {
    let identifier: String
    let coordinateSpace: CoordinateSpace

    func body(content: Content) -> some View {
    content.background(
    GeometryReader { geometry in
    Color.clear
    .preference(key: CellFrameKey.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 FrameStorage: 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(CellFrameKey.self) { value in
    switch self.animation {
    case .automatic:
    withAnimation {
    self.frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b })
    }
    case .explicit(let animation):
    withAnimation(animation) {
    self.frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b })
    }
    }
    }
    }
    }

    /// A view modifier that stores the captured geometry of a single view in a binding
    struct FrameAssignment: ViewModifier {
    enum AnimationBehaviour {
    case automatic
    case explicit(_ animation: Animation)
    }

    let frameStore: Binding<CGRect>
    let animation: AnimationBehaviour
    let identifier: String

    init(identifier: String, binding: Binding<CGRect>, animation: Animation? = nil) {
    self.identifier = identifier
    if let explicitAnimation = animation {
    self.animation = .explicit(explicitAnimation)
    } else {
    self.animation = .automatic
    }
    self.frameStore = binding
    }

    func body(content: Content) -> some View {
    content.onPreferenceChange(CellFrameKey.self) { viewFrameData in
    guard let data = viewFrameData[self.identifier] else {
    return
    }
    switch self.animation {
    case .automatic:
    withAnimation {
    self.frameStore.wrappedValue = data.frame
    }
    case .explicit(let animation):
    withAnimation(animation) {
    self.frameStore.wrappedValue = data.frame
    }
    }
    }
    }
    }

    /// Convenience functions for the modifiers
    extension View {
    func capturingFrame(identifier: String, coordinateSpace: CoordinateSpace) -> some View {
    modifier(FrameCapture(identifier: identifier, coordinateSpace: coordinateSpace))
    }

    func storeFrames(in frameData: Binding<[String:ViewFrameData]>) -> some View {
    modifier(FrameStorage(frameData: frameData))
    }

    func storeFrames(in frameData: Binding<[String:ViewFrameData]>, animation: Animation) -> some View {
    modifier(FrameStorage(frameData: frameData, animation: animation))
    }

    func storeFrame(of identifier: String, in binding: Binding<CGRect>) -> some View {
    modifier(FrameAssignment(identifier: identifier, binding: binding))
    }

    }
    352 changes: 352 additions & 0 deletions FrameCaptureModifier.swift
    Original file line number Diff line number Diff line change
    @@ -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()
    }
    }
  2. @marcpalmer marcpalmer created this gist Nov 10, 2020.
    140 changes: 140 additions & 0 deletions FrameCapture.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,140 @@
    //
    // FrameCapture.swift
    // Captionista
    //
    // Created by Marc Palmer on 31/03/2020.
    // Copyright © 2020 Montana Floss Co. Ltd. All rights reserved.
    //

    import Foundation
    import SwiftUI

    /// 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 CellFrameKey: PreferenceKey {
    typealias Value = [String:ViewFrameData]

    static var defaultValue: [String:ViewFrameData] = [:]

    static func reduce(value: inout [String:ViewFrameData], nextValue: () -> [String:ViewFrameData]) {
    value.merge(nextValue(), uniquingKeysWith: { a, b in b })
    }
    }

    /// A view modifier that captures the geometry of the View in a preference
    struct FrameCapture: ViewModifier {
    let identifier: String
    let coordinateSpace: CoordinateSpace

    func body(content: Content) -> some View {
    content.background(
    GeometryReader { geometry in
    Color.clear
    .preference(key: CellFrameKey.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 FrameStorage: 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(CellFrameKey.self) { value in
    switch self.animation {
    case .automatic:
    withAnimation {
    self.frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b })
    }
    case .explicit(let animation):
    withAnimation(animation) {
    self.frameData.wrappedValue.merge(value, uniquingKeysWith: { a, b in b })
    }
    }
    }
    }
    }

    /// A view modifier that stores the captured geometry of a single view in a binding
    struct FrameAssignment: ViewModifier {
    enum AnimationBehaviour {
    case automatic
    case explicit(_ animation: Animation)
    }

    let frameStore: Binding<CGRect>
    let animation: AnimationBehaviour
    let identifier: String

    init(identifier: String, binding: Binding<CGRect>, animation: Animation? = nil) {
    self.identifier = identifier
    if let explicitAnimation = animation {
    self.animation = .explicit(explicitAnimation)
    } else {
    self.animation = .automatic
    }
    self.frameStore = binding
    }

    func body(content: Content) -> some View {
    content.onPreferenceChange(CellFrameKey.self) { viewFrameData in
    guard let data = viewFrameData[self.identifier] else {
    return
    }
    switch self.animation {
    case .automatic:
    withAnimation {
    self.frameStore.wrappedValue = data.frame
    }
    case .explicit(let animation):
    withAnimation(animation) {
    self.frameStore.wrappedValue = data.frame
    }
    }
    }
    }
    }

    /// Convenience functions for the modifiers
    extension View {
    func capturingFrame(identifier: String, coordinateSpace: CoordinateSpace) -> some View {
    modifier(FrameCapture(identifier: identifier, coordinateSpace: coordinateSpace))
    }

    func storeFrames(in frameData: Binding<[String:ViewFrameData]>) -> some View {
    modifier(FrameStorage(frameData: frameData))
    }

    func storeFrames(in frameData: Binding<[String:ViewFrameData]>, animation: Animation) -> some View {
    modifier(FrameStorage(frameData: frameData, animation: animation))
    }

    func storeFrame(of identifier: String, in binding: Binding<CGRect>) -> some View {
    modifier(FrameAssignment(identifier: identifier, binding: binding))
    }

    }