├── Draggable UI ├── DraggableUI.swift ├── DraggableUIApp.swift └── ContentView.swift └── README.md /Draggable UI/DraggableUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // DragTest 4 | // 5 | // Created by Ordinary Industries on 12/30/23. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | 13 | import SwiftUI 14 | 15 | @main 16 | struct DraggableUIApp: App { 17 | var body: some Scene { 18 | WindowGroup { 19 | ContentView() 20 | .persistentSystemOverlays(.hidden) 21 | .statusBarHidden() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Draggable UI/DraggableUIApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // DragTest 4 | // 5 | // Created by Ordinary Industries on 12/30/23. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | 13 | import SwiftUI 14 | 15 | @main 16 | struct DraggableUIApp: App { 17 | var body: some Scene { 18 | WindowGroup { 19 | ContentView() 20 | .persistentSystemOverlays(.hidden) 21 | .statusBarHidden() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Draggable UI 2 | A draggable button written entirely in SwiftUI. 3 | - Long press the button to unlock dragging. 4 | - When dragging is unlocked a boundary box is displayed to show where the button can be dropped. 5 | - Drag the button anywhere in the boundary box. 6 | - Button snaps to the left and right edges. 7 | - Dragging the button outside the boundary box snaps it to the nearest point inside the boundary box. 8 | - The bottom corners of the boundary box are weighted. Dragging the button near the corner snaps to that corner. 9 | 10 | https://github.com/ordinaryindustries/draggable-ui/assets/132616209/5f0fb909-6db6-4c82-82f8-5b59c7c70fb3 11 | 12 | -------------------------------------------------------------------------------- /Draggable UI/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // DragTest 4 | // 5 | // Created by Ordinary Industries on 12/30/23. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | 13 | import SwiftUI 14 | 15 | struct ContentView: View { 16 | @State private var dragOffset: CGSize = .zero 17 | @State private var position: CGSize = .zero 18 | @State private var canDrag: Bool = false 19 | let buttonSize: CGFloat = 80 20 | let dragDamping: CGFloat = 0.36 21 | let buttonScale: CGFloat = 0.9 22 | let cornerSnapMargin: CGFloat = 100 23 | 24 | var body: some View { 25 | VStack { 26 | Spacer() 27 | GeometryReader { proxy in 28 | if canDrag { 29 | withAnimation { 30 | RoundedRectangle(cornerRadius: 40) 31 | .fill(.clear) 32 | .strokeBorder(.white, style: StrokeStyle(lineWidth: 2.0, lineCap: .round, dash: [10])) 33 | .frame(maxWidth: .infinity, maxHeight: .infinity) 34 | .opacity(0.2) 35 | } 36 | } 37 | 38 | Circle() 39 | .fill(.white) 40 | .frame(width: canDrag ? buttonSize * buttonScale : buttonSize) 41 | .offset(x: dragOffset.width + position.width, y: dragOffset.height + position.height) 42 | .onAppear { 43 | position.width = proxy.frame(in: .local).maxX - buttonSize 44 | position.height = proxy.frame(in: .local).maxY - buttonSize 45 | } 46 | .gesture( 47 | LongPressGesture(minimumDuration: 0.4) 48 | .onEnded({ _ in 49 | print("Long press ended.") 50 | withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { 51 | canDrag.toggle() 52 | } 53 | }) 54 | ) 55 | .sensoryFeedback(.impact, trigger: canDrag) 56 | .simultaneousGesture( 57 | DragGesture() 58 | .onChanged({ value in 59 | if canDrag { 60 | withAnimation() { 61 | print("Position: \(position.height) Drag distance \(value.translation.height)") 62 | 63 | if position.height + value.translation.height < .zero { 64 | print("Out of bounds!") 65 | 66 | dragOffset.height = value.translation.height 67 | dragOffset.width = value.translation.width 68 | } else { 69 | dragOffset = value.translation 70 | } 71 | } 72 | } 73 | }) 74 | .onEnded({ value in 75 | if canDrag { 76 | // When button is let go set it's position. 77 | withAnimation() { 78 | position.width += value.translation.width 79 | position.height += value.translation.height 80 | 81 | // Reset the drag delta. 82 | dragOffset = .zero 83 | 84 | 85 | // Calculate x position. 86 | if position.width + buttonSize / 2 < proxy.size.width / 2 { 87 | position.width = .zero 88 | } else { 89 | position.width = proxy.size.width - buttonSize 90 | } 91 | 92 | // Calculate y position. 93 | // If the button is above the draggable area. 94 | if position.height < .zero { 95 | position.height = .zero 96 | // If the button is below the draggable area. 97 | } else if position.height > proxy.size.height - buttonSize { 98 | position.height = proxy.size.height - buttonSize 99 | } else if proxy.size.height - position.height < cornerSnapMargin { 100 | position.height = proxy.size.height - buttonSize 101 | } 102 | 103 | } 104 | withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { 105 | canDrag.toggle() 106 | } 107 | } 108 | 109 | }) 110 | 111 | ) 112 | } 113 | .frame(maxWidth: .infinity) 114 | .frame(height: 400) 115 | .padding() 116 | } 117 | .background(.black) 118 | .ignoresSafeArea() 119 | 120 | } 121 | } 122 | 123 | #Preview { 124 | ContentView() 125 | } 126 | --------------------------------------------------------------------------------