Skip to content

Instantly share code, notes, and snippets.

@connor-ricks
Created July 2, 2024 16:01
Show Gist options
  • Save connor-ricks/b25861b0b6aee6f18f1ea5ff7a255a0a to your computer and use it in GitHub Desktop.
Save connor-ricks/b25861b0b6aee6f18f1ea5ff7a255a0a to your computer and use it in GitHub Desktop.
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
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment