Skip to content

Instantly share code, notes, and snippets.

@adam-zethraeus
Last active May 6, 2026 20:03
Show Gist options
  • Select an option

  • Save adam-zethraeus/c63c8f13eac929d382d6955299257eaf to your computer and use it in GitHub Desktop.

Select an option

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
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