Created
July 2, 2024 16:01
-
-
Save connor-ricks/b25861b0b6aee6f18f1ea5ff7a255a0a to your computer and use it in GitHub Desktop.
Revisions
-
connor-ricks created this gist
Jul 2, 2024 .There are no files selected for viewing
This file contains hidden or 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 charactersOriginal 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 } } }