Created
July 2, 2024 16:01
-
-
Save connor-ricks/b25861b0b6aee6f18f1ea5ff7a255a0a to your computer and use it in GitHub Desktop.
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
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