Skip to content

Instantly share code, notes, and snippets.

@connor-ricks
Created July 2, 2024 16:01
Show Gist options
  • Select an option

  • Save connor-ricks/b25861b0b6aee6f18f1ea5ff7a255a0a to your computer and use it in GitHub Desktop.

Select an option

Save connor-ricks/b25861b0b6aee6f18f1ea5ff7a255a0a to your computer and use it in GitHub Desktop.

Revisions

  1. connor-ricks created this gist Jul 2, 2024.
    371 changes: 371 additions & 0 deletions ManipulatableView.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,371 @@
    import SwiftUI

    #warning("TODO: <Connor> Make handles generic views so they are customizable rather than assuming what people want. (Provide defaults)")
    #warning("TODO: <Connor> Use my custom @StateBinding property wrapper to allow consumers to optionally pass a binding to the bools for isExpanded, isMoving, isResizing. Then they can react in the parent, or they can not pass a binding and let the view handle the state internally.")
    #warning("TODO: <Connor> General cleanup of calculations and code density.")

    // MARK: - ViewManipulation

    struct ViewManipulation: OptionSet {

    // MARK: Properties

    let rawValue: UInt

    // MARK: Options

    static let resizeable = ViewManipulation(rawValue: 1 << 0)
    static let moveable = ViewManipulation(rawValue: 1 << 1)
    static let expandable = ViewManipulation(rawValue: 1 << 2)
    static let all: ViewManipulation = [.resizeable, .moveable, .expandable]
    }

    // MARK: - ManiupulatableView

    /// Attaches handles to the view allowing user manipulation of the view's scale and location.
    struct ManiupulatableView: ViewModifier {

    // MARK: Constants

    private enum Constants {
    static let handlePadding: CGFloat = 12
    static let handleOpacity: CGFloat = 0.4
    static let handleActiveColor: Color = .blue
    static let handleInactiveColor: Color = .black
    static let movementHandleSize: CGSize = .init(width: 32, height: 6)
    static let scaleHandleSize: CGSize = .init(width: 16, height: 5)

    static func snapAnimation(for velocity: CGFloat) -> Animation {
    .interpolatingSpring(mass: 1.0, stiffness: 134, damping: 16, initialVelocity: velocity)
    }
    }

    // MARK: Properties

    /// The default and preferred size of the content.
    let size: CGSize

    /// The minimum scale of the content.
    /// (i.e If the size is 200x150, with a minimumScale of 0.5, then the smallest the view can be is 100x75)
    let minimumScale: CGFloat = 0.5

    /// The desired manipulations that are available.
    let options: ViewManipulation

    // MARK: Body

    func body(content: Content) -> some View {
    GeometryReader { playground in
    VStack {
    content
    .cornerRadius(isExpanded ? 0 : 10)
    .frame(
    maxWidth: isExpanded ? .infinity : scaleSize.width <= 0 ? size.width : size.width + size.width * scaleSize.width,
    maxHeight: isExpanded ? .infinity : scaleSize.height <= 0 ? size.height : size.height + size.height * scaleSize.height
    )
    .gesture(expandGesture)
    .scaleEffect(x: scaleSize.width <= 0 ? 1 + scaleSize.width : 1)
    .scaleEffect(y: scaleSize.height <= 0 ? 1 + scaleSize.height : 1)
    .overlay {
    if !isExpanded, options.contains(.resizeable) { scaleHandle(playground: playground) }
    }
    .overlay {
    if !isExpanded, options.contains(.moveable) { movementHandle(playground: playground) }
    }
    .offset(x: movementOffset.x, y: movementOffset.y)
    .padding(isExpanded || options.isEmpty ? 0 : Constants.handlePadding)
    }
    }
    .coordinateSpace(name: "playground")
    }

    // MARK: Expandable

    @State private var previousPreviewState: (scale: CGSize, offset: CGPoint) = (.zero, .zero)
    @State private var isExpanded: Bool = false

    private var expandGesture: some Gesture {
    TapGesture(count: 2)
    .onEnded { _ in
    guard options.contains(.expandable) else { return }

    withAnimation {
    isExpanded.toggle()

    let previousPreviewState = previousPreviewState
    self.previousPreviewState = isExpanded ? (scaleSize, movementOffset) : (.zero, .zero)

    scaleSize = isExpanded ? .zero : previousPreviewState.scale
    movementOffset = isExpanded ? .zero : previousPreviewState.offset
    }
    }
    }

    // MARK: Scale

    @State private var isAdjustingScale: Bool = false
    @State private var scaleSize: CGSize = .zero
    @GestureState private var startScaleSize: CGSize? = nil

    private func scaleHandle(playground: GeometryProxy) -> some View {
    GeometryReader { toy in
    ZStack(alignment: currentSnapPosition.inverseAlignment) {
    Capsule().frame(width: Constants.scaleHandleSize.width, height: Constants.scaleHandleSize.height)
    Capsule().frame(width: Constants.scaleHandleSize.height, height: Constants.scaleHandleSize.width)
    }
    .foregroundStyle(isAdjustingScale ? Constants.handleActiveColor : Constants.handleInactiveColor)
    .compositingGroup()
    .opacity(Constants.handleOpacity)
    .animation(.default.speed(2), value: isAdjustingScale)
    .offset(scaleHandleOffset(toy: toy))
    .gesture(scaleGesture(playground: playground, toy: toy))
    }
    }

    private func scaleGesture(playground: GeometryProxy, toy: GeometryProxy) -> some Gesture {
    DragGesture(minimumDistance: .zero, coordinateSpace: .global)
    .onChanged { value in
    isAdjustingScale = true
    var newScaleSize = startScaleSize ?? scaleSize
    let translation = value.translation

    let relativeTranslation = switch currentSnapPosition {
    case .topLeading:
    CGSize(width: translation.width, height: translation.height)
    case .topTrailing:
    CGSize(width: -translation.width, height: translation.height)
    case .bottomLeading:
    CGSize(width: translation.width, height: -translation.height)
    case .bottomTrailing:
    CGSize(width: -translation.width, height: -translation.height)
    }

    newScaleSize.width += relativeTranslation.width / 150
    newScaleSize.height += relativeTranslation.height / 200

    switch (newScaleSize.width <= 0, newScaleSize.height <= 0) {
    case (false, false):
    // Both the height and width can be manipulated separately.
    break
    case (true, true):
    // Both the height and width can be manipulated together.
    newScaleSize.width = max(newScaleSize.width, newScaleSize.height)
    newScaleSize.height = max(newScaleSize.width, newScaleSize.height)
    case (true, false):
    // Only the height can be manipulated.
    newScaleSize.width = 0
    case (false, true):
    // Only the width can be manipulated.
    newScaleSize.height = 0
    }

    let isWidthTooSmall = 1 + newScaleSize.width < 0.5
    let isHeightTooSmall = 1 + newScaleSize.height < 0.5
    guard !isWidthTooSmall, !isHeightTooSmall else { return }

    scaleSize = newScaleSize
    movementOffset = movementOffset(playground: playground, toy: toy)
    }
    .onEnded { value in
    isAdjustingScale = false
    }
    .updating($startScaleSize) { (value, startScaleSize, transaction) in
    startScaleSize = startScaleSize ?? scaleSize
    }
    }

    private func scaleHandleOffset(toy: GeometryProxy) -> CGSize {
    switch currentSnapPosition {
    case .topLeading:
    CGSize(
    width: (toy.size.width - Constants.scaleHandleSize.height) + (scaleSize.width < 0 ? toy.size.width * scaleSize.width / 2 : 0),
    height: (toy.size.height - Constants.scaleHandleSize.height) + (scaleSize.height < 0 ? toy.size.height * scaleSize.height / 2 : 0)
    )
    case .topTrailing:
    CGSize(
    width: (-Constants.scaleHandleSize.height * 2) + (scaleSize.width < 0 ? -toy.size.width * scaleSize.width / 2 : 0),
    height: (toy.size.height - Constants.scaleHandleSize.height) + (scaleSize.height < 0 ? toy.size.height * scaleSize.width / 2 : 0)
    )
    case .bottomLeading:
    CGSize(
    width: (toy.size.width - Constants.scaleHandleSize.height) + (scaleSize.width < 0 ? toy.size.width * scaleSize.width / 2 : 0),
    height: (-Constants.scaleHandleSize.height * 2) + (scaleSize.height < 0 ? -toy.size.height * scaleSize.width / 2 : 0)
    )
    case .bottomTrailing:
    CGSize(
    width: (-Constants.scaleHandleSize.height * 2) + (scaleSize.width < 0 ? -toy.size.width * scaleSize.width / 2 : 0),
    height: (-Constants.scaleHandleSize.height * 2) + (scaleSize.height < 0 ? -toy.size.height * scaleSize.width / 2 : 0)
    )
    }
    }

    // MARK: Offset

    @State private var currentSnapPosition: SnapPosition = .topLeading
    @State private var isAdjustingMovement: Bool = false
    @State private var movementVelocity: CGFloat = .zero
    @State private var movementOffset: CGPoint = .zero
    @GestureState private var movementStartOffset: CGPoint? = nil

    private func movementHandle(playground: GeometryProxy) -> some View {
    GeometryReader { toy in
    Capsule()
    .frame(
    width: Constants.movementHandleSize.width,
    height: Constants.movementHandleSize.height
    )
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
    .offset(y: -Constants.movementHandleSize.height * 2)
    .foregroundStyle(isAdjustingMovement ? Constants.handleActiveColor : Constants.handleInactiveColor)
    .opacity(Constants.handleOpacity)
    .animation(.default.speed(2), value: isAdjustingMovement)
    .offset(y: toy.size.height * (scaleSize.height < 0 ? scaleSize.height / -2 : 0))
    .gesture(movementGesture(playground: playground, toy: toy))
    .onAppear { movementOffset = movementOffset(playground: playground, toy: toy) }
    }
    }

    private func movementGesture(playground: GeometryProxy, toy: GeometryProxy) -> some Gesture {
    DragGesture(minimumDistance: .zero, coordinateSpace: .global)
    .onChanged { value in

    let frame = toy.frame(in: .named("playground"))
    isAdjustingMovement = true
    currentSnapPosition = SnapPosition(
    playground: playground,
    location: .init(
    x: frame.midX,
    y: frame.midY
    )
    )

    var newOffset = movementStartOffset ?? movementOffset
    newOffset.x += value.translation.width
    newOffset.y += value.translation.height
    movementOffset = newOffset
    }
    .onEnded { value in
    isAdjustingMovement = false
    let frame = toy.frame(in: .named("playground"))
    currentSnapPosition = SnapPosition(playground: playground, location: .init(x: frame.midX, y: frame.midY))

    let velocityX = (value.predictedEndLocation.x - value.location.x) / value.predictedEndLocation.x
    let velocityY = (value.predictedEndLocation.y - value.location.y) / value.predictedEndLocation.y
    let velocity = sqrt(pow(velocityX, 2) + pow(velocityY, 2))
    withAnimation(Constants.snapAnimation(for: velocity)) {
    movementOffset = movementOffset(playground: playground, toy: toy)
    }
    }
    .updating($movementStartOffset) { (value, movementStartOffset, transaction) in
    movementStartOffset = movementStartOffset ?? movementOffset
    }
    }

    private func movementOffset(playground: GeometryProxy, toy: GeometryProxy) -> CGPoint {
    let xScaleOffset: CGFloat = scaleSize.width < 0 ? (toy.size.width * (1 + scaleSize.width) - toy.size.width) / 2 : 0
    let yScaleOffset: CGFloat = scaleSize.height < 0 ? (toy.size.height * (1 + scaleSize.height) - toy.size.height) / 2 : 0

    return switch currentSnapPosition {
    case .topLeading:
    CGPoint(x: xScaleOffset, y: yScaleOffset)
    case .topTrailing:
    .init(
    x: playground.size.width - toy.size.width - Constants.handlePadding * 2 - xScaleOffset,
    y: yScaleOffset
    )
    case .bottomLeading:
    .init(
    x: xScaleOffset,
    y: playground.size.height - toy.size.height - Constants.handlePadding * 2 - yScaleOffset
    )
    case .bottomTrailing:
    .init(
    x: playground.size.width - toy.size.width - Constants.handlePadding * 2 - xScaleOffset,
    y: playground.size.height - toy.size.height - Constants.handlePadding * 2 - yScaleOffset
    )
    }
    }
    }

    // MARK: View + Manipulatable

    extension View {
    /// Allows user manipulation of the view's scale and location.
    func manipulatable(_ options: ViewManipulation = .all, size: CGSize) -> some View {
    self.modifier(ManiupulatableView(size: size, options: options))
    }
    }

    // MARK: Manipulatable + Preview

    #Preview("Spaced") {
    VStack {
    Rectangle()
    .foregroundStyle(.red)
    .frame(height: 100)
    List {
    Text("Row #1")
    Text("Row #2")
    Text("Row #3")
    Text("Row #4")
    }
    .manipulatable(size: .init(width: 150, height: 200))
    Rectangle()
    .foregroundStyle(.blue)
    .frame(height: 100)
    }
    }

    #Preview("Fullscreen") {
    VStack {
    List {
    Text("Row #1")
    Text("Row #2")
    Text("Row #3")
    Text("Row #4")
    }
    .manipulatable(size: .init(width: 150, height: 200))
    }
    }

    // MARK: - SnapPosition

    enum SnapPosition {
    case topLeading
    case topTrailing
    case bottomLeading
    case bottomTrailing

    // MARK: Initializers

    init(playground: GeometryProxy, location: CGPoint) {
    let isLeading = location.x < playground.size.width / 2
    let isTop = location.y < playground.size.height / 2

    switch (isTop, isLeading) {
    case (false, false):
    self = .bottomTrailing
    case (false, true):
    self = .bottomLeading
    case (true, false):
    self = .topTrailing
    case (true, true):
    self = .topLeading
    }
    }

    // MARK: Helpers

    var inverseAlignment: Alignment {
    switch self {
    case .topLeading:
    .bottomTrailing
    case .topTrailing:
    .bottomLeading
    case .bottomLeading:
    .topTrailing
    case .bottomTrailing:
    .topLeading
    }
    }
    }