Last active
May 6, 2026 20:03
-
-
Save adam-zethraeus/c63c8f13eac929d382d6955299257eaf to your computer and use it in GitHub Desktop.
SwiftUI: Fully swipe-able content + sidebar surface, with functional row swipe actions
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 characters
| import SwiftUI | |
| import UIKit | |
| struct ContentView: View { | |
| @State var sidebarItems: [String] = [ | |
| "sidebar-item1", | |
| "sidebar-item2", | |
| "sidebar-item3", | |
| "sidebar-item4", | |
| "sidebar-item5", | |
| "sidebar-item6", | |
| "sidebar-item7", | |
| ] | |
| @State var contentItems: [String] = [ | |
| "content-item1", | |
| "content-item2", | |
| "content-item3", | |
| "content-item4", | |
| "content-item5", | |
| "content-item6", | |
| "content-item7", | |
| ] | |
| @State var size: CGSize = .zero | |
| @State var safeArea: EdgeInsets = .init() | |
| @State var enableGesture: Bool = true | |
| @State var swipe: CGFloat = 0 | |
| @State var swipeOffset: CGFloat = 0 | |
| static let sidebarProportion: CGFloat = 0.8 | |
| static let maxSidebarWidth: CGFloat = 350 | |
| var sw: CGFloat { | |
| min(size.width*Self.sidebarProportion, Self.maxSidebarWidth) | |
| } | |
| var body: some View { | |
| Rectangle() | |
| .fill(.red) | |
| .readingSize(into: $size, safeArea: $safeArea) | |
| .ignoresSafeArea() | |
| .contentShape(.rect) | |
| .overlay(alignment: .trailing) { | |
| HStack(spacing: 0) { | |
| List { | |
| ForEach(sidebarItems, id: \.self) { id in | |
| SidebarItem(id: id) { | |
| print(id) | |
| } | |
| .swipeActions(edge: .leading, allowsFullSwipe: false) { | |
| Button( | |
| "Action", | |
| systemImage: "trash.circle.fill", | |
| role: .destructive, | |
| action: { | |
| print("\(id) action") | |
| }) | |
| Button( | |
| "Other", | |
| systemImage: "checkmark.circle.fill", role: .confirm, | |
| action: { | |
| print("\(id) action") | |
| }) | |
| } | |
| } | |
| .listRowBackground(Color.black) | |
| } | |
| .scrollContentBackground(.hidden) | |
| .background(Color.black) | |
| .frame( | |
| width: min(size.width * Self.sidebarProportion, | |
| Self.maxSidebarWidth) | |
| ) | |
| List { | |
| ForEach(contentItems, id: \.self) { id in | |
| ContentItem(id: id) { | |
| print(id) | |
| } | |
| .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| Button("Action", systemImage: "trash.circle.fill", role: .destructive, action: { | |
| print("\(id) action") | |
| }) | |
| Button("Other", systemImage: "checkmark.circle.fill", role: .confirm, action: { | |
| print("\(id) action") | |
| }) | |
| } | |
| } | |
| .listRowBackground(Color.clear) | |
| } | |
| .scrollContentBackground(.hidden) | |
| .frame( | |
| width:size.width | |
| ) | |
| } | |
| } | |
| .background { | |
| DrawerPanGestureBridge( | |
| sidebarWidth: sw, | |
| offset: $swipeOffset, | |
| translation: $swipe | |
| ) | |
| } | |
| .offset(x: swipe + swipeOffset) | |
| } | |
| } | |
| struct SidebarItem: View { | |
| let id: String | |
| let action: () -> Void | |
| var body: some View { | |
| Button {action()} label: { | |
| Text(id) | |
| } | |
| } | |
| } | |
| struct ContentItem: View { | |
| let id: String | |
| let action: () -> Void | |
| var body: some View { | |
| Button { | |
| action() | |
| } label: { | |
| Text(id) | |
| } | |
| } | |
| } | |
| private struct DrawerPanGestureBridge: UIViewRepresentable { | |
| let sidebarWidth: CGFloat | |
| @Binding var offset: CGFloat | |
| @Binding var translation: CGFloat | |
| func makeCoordinator() -> Coordinator { | |
| Coordinator( | |
| sidebarWidth: sidebarWidth, | |
| offset: $offset, | |
| translation: $translation | |
| ) | |
| } | |
| func makeUIView(context: Context) -> UIView { | |
| let view = UIView(frame: .zero) | |
| view.isUserInteractionEnabled = false | |
| return view | |
| } | |
| func updateUIView(_ uiView: UIView, context: Context) { | |
| context.coordinator.sidebarWidth = sidebarWidth | |
| context.coordinator.offset = $offset | |
| context.coordinator.translation = $translation | |
| DispatchQueue.main.async { | |
| context.coordinator.attach(to: uiView.window ?? uiView.superview) | |
| } | |
| } | |
| static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { | |
| coordinator.detach() | |
| } | |
| final class Coordinator: NSObject, UIGestureRecognizerDelegate { | |
| var sidebarWidth: CGFloat | |
| var offset: Binding<CGFloat> | |
| var translation: Binding<CGFloat> | |
| private let pan: UIPanGestureRecognizer | |
| private weak var attachedView: UIView? | |
| private var startOffset: CGFloat = 0 | |
| init( | |
| sidebarWidth: CGFloat, | |
| offset: Binding<CGFloat>, | |
| translation: Binding<CGFloat> | |
| ) { | |
| self.sidebarWidth = sidebarWidth | |
| self.offset = offset | |
| self.translation = translation | |
| self.pan = UIPanGestureRecognizer() | |
| super.init() | |
| pan.addTarget(self, action: #selector(handlePan(_:))) | |
| pan.delegate = self | |
| pan.cancelsTouchesInView = true | |
| pan.delaysTouchesBegan = false | |
| pan.delaysTouchesEnded = false | |
| } | |
| func attach(to view: UIView?) { | |
| guard let view, attachedView !== view else { return } | |
| detach() | |
| attachedView = view | |
| view.addGestureRecognizer(pan) | |
| } | |
| func detach() { | |
| attachedView?.removeGestureRecognizer(pan) | |
| attachedView = nil | |
| } | |
| func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { | |
| guard gestureRecognizer === pan, sidebarWidth > 0 else { return false } | |
| let velocity = pan.velocity(in: pan.view) | |
| let movement = pan.translation(in: pan.view) | |
| let horizontal = max(abs(velocity.x), abs(movement.x)) | |
| let vertical = max(abs(velocity.y), abs(movement.y)) | |
| guard horizontal > vertical * 1.2 else { return false } | |
| let direction = velocity.x == 0 ? movement.x : velocity.x | |
| let currentOffset = clamped(offset.wrappedValue) | |
| if currentOffset <= 0.5 { | |
| return direction > 0 | |
| } | |
| if currentOffset >= sidebarWidth - 0.5 { | |
| return direction < 0 | |
| } | |
| return direction != 0 | |
| } | |
| func gestureRecognizer( | |
| _ gestureRecognizer: UIGestureRecognizer, | |
| shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer | |
| ) -> Bool { | |
| false | |
| } | |
| @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { | |
| guard sidebarWidth > 0 else { return } | |
| switch recognizer.state { | |
| case .began: | |
| startOffset = clamped(offset.wrappedValue) | |
| translation.wrappedValue = 0 | |
| case .changed: | |
| let proposedOffset = startOffset + recognizer.translation(in: recognizer.view).x | |
| translation.wrappedValue = clamped(proposedOffset) - startOffset | |
| case .ended: | |
| settlePan(recognizer) | |
| case .cancelled, .failed: | |
| withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.9)) { | |
| translation.wrappedValue = 0 | |
| } | |
| default: | |
| break | |
| } | |
| } | |
| private func settlePan(_ recognizer: UIPanGestureRecognizer) { | |
| let proposedOffset = startOffset + recognizer.translation(in: recognizer.view).x | |
| let currentOffset = clamped(proposedOffset) | |
| let velocity = recognizer.velocity(in: recognizer.view).x | |
| let projectedOffset = currentOffset + velocity * 0.18 | |
| let targetOffset = projectedOffset < sidebarWidth * 0.5 ? 0.0 : sidebarWidth | |
| var transaction = Transaction() | |
| transaction.disablesAnimations = true | |
| withTransaction(transaction) { | |
| offset.wrappedValue = currentOffset | |
| translation.wrappedValue = 0 | |
| } | |
| withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.9)) { | |
| offset.wrappedValue = targetOffset | |
| } | |
| } | |
| private func clamped(_ proposedOffset: CGFloat) -> CGFloat { | |
| Swift.min(Swift.max(proposedOffset, 0), sidebarWidth) | |
| } | |
| } | |
| } | |
| #Preview { | |
| ContentView() | |
| } | |
| extension AnyHashable { | |
| public init<each T: Hashable>(many: repeat each T) { | |
| var group: [AnyHashable] = [] | |
| for a in repeat each many { | |
| group.append(AnyHashable(a)) | |
| } | |
| self = .init(group) | |
| } | |
| } | |
| struct Edges: Hashable { | |
| let insets: EdgeInsets | |
| static func == (lhs: Edges, rhs: Edges) -> Bool { | |
| let l = lhs.components | |
| let r = rhs.components | |
| return l.top == r.top && l.leading == r.leading && l.bottom == r.bottom && l.trailing == r.trailing | |
| } | |
| func hash(into hasher: inout Hasher) { | |
| let c = components | |
| hasher.combine(c.top) | |
| hasher.combine(c.leading) | |
| hasher.combine(c.bottom) | |
| hasher.combine(c.trailing) | |
| } | |
| private var components: (top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { | |
| (insets.top, insets.leading, insets.bottom, insets.trailing) | |
| } | |
| } | |
| extension View { | |
| func readingSize(into size: Binding<CGSize>, safeArea: Binding<EdgeInsets> = .constant(.init())) -> some View { | |
| self | |
| .background { | |
| GeometryReader { proxy in | |
| Color.clear | |
| .task(id: AnyHashable(many: proxy.size, Edges(insets: proxy.safeAreaInsets))) { | |
| size.wrappedValue = proxy.size | |
| safeArea.wrappedValue = proxy.safeAreaInsets | |
| } | |
| } | |
| .hidden() | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment