From 04e67e89f2384e76178270ede93f6813e94291d0 Mon Sep 17 00:00:00 2001 From: Marvelous Akpotu Date: Wed, 21 Oct 2026 14:54:07 +0100 Subject: [PATCH 1/6] feat: drag and drop to rearrange tabs Tabs are drag sources (NSItemProvider with the tab URL) and drop targets (plainText onDrop): dropping a tab onto another takes its position, browser-style. PDFReaderState.moveTab re-syncs the active tab index from its URL after the move so the displayed document never reloads, and the reordered session persists through the tabURLs observer. The hovered target shows a dashed accent border. Per-tab drag/drop wiring lives in a small TabItem view so the modifier chain type-checks fast. --- README.md | 9 +-- Sources/PDFReader/Views/ContentView.swift | 72 +++++++++++++++++-- .../PDFReaderKit/Models/PDFReaderState.swift | 21 ++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f10266b..15b0832 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,11 @@ Native macOS PDF reader built with SwiftUI + PDFKit as a Swift Package - **Reading modes** — layout menu, book mode, RTL, night background, presentation (full-screen). - **Tabs + session restore** — open multiple PDFs as tabs in the tab bar - (⌘T / + button); click to switch, × to close. Switching back to a tab - returns to the exact section of the page you left (not just the page - top); quitting and relaunching reopens the same tabs with each file - back on the page you left it. + (⌘T / + button); click to switch, × to close, drag onto another tab to + rearrange them (the active document never reloads while reordering). + Switching back to a tab returns to the exact section of the page you + left (not just the page top); quitting and relaunching reopens the + same tabs with each file back on the page you left it. - **Settings** — ⌘, opens Settings; toggle "Remember where I left off in each file" (on by default) to control last-position restore. - **Updates** — Help → Check for Updates… compares the build's version diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index 3ea9e31..b7ca4a6 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -781,10 +781,12 @@ extension View { // MARK: - Tab Bar -/// One tab: filename, click to switch, × to close. +/// One tab: filename, click to switch, × to close. Also a drag source +/// (rearrange) and drop target for sibling tabs. struct TabButton: View { let title: String let isActive: Bool + var isDropTarget: Bool = false let onSelect: () -> Void let onClose: () -> Void @@ -818,8 +820,12 @@ struct TabButton: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(isActive ? Color.accentColor.opacity(0.5) : .clear, lineWidth: 1) + .strokeBorder( + isDropTarget ? Color.accentColor : (isActive ? Color.accentColor.opacity(0.5) : .clear), + style: StrokeStyle(lineWidth: 1.5, dash: isDropTarget ? [3, 3] : []) + ) ) + .opacity(isDropTarget ? 0.6 : 1) .contentShape(Rectangle()) .onTapGesture(perform: onSelect) .help(title) @@ -827,17 +833,20 @@ struct TabButton: View { } /// Adobe-style tab bar, left-aligned, with a New Tab (+) button. +/// Tabs are drag sources and drop targets: drop a tab onto another to +/// take its position (browser-style reorder). struct TabBar: View { @ObservedObject var state: PDFReaderState + @State private var dropTargetIndex: Int? var body: some View { HStack(spacing: 4) { ForEach(Array(state.tabURLs.enumerated()), id: \.element) { index, url in - TabButton( - title: url.lastPathComponent, - isActive: state.activeTabIndex == index, - onSelect: { state.switchToTab(index) }, - onClose: { state.closeTab(at: index) } + TabItem( + state: state, + url: url, + index: index, + dropTargetIndex: $dropTargetIndex ) } Button { @@ -871,3 +880,52 @@ struct TabBar: View { } } } + +/// One tab plus its drag-and-drop wiring, split out of `TabBar` so the +/// modifier chain type-checks fast. +private struct TabItem: View { + @ObservedObject var state: PDFReaderState + let url: URL + let index: Int + @Binding var dropTargetIndex: Int? + + var body: some View { + TabButton( + title: url.lastPathComponent, + isActive: state.activeTabIndex == index, + isDropTarget: dropTargetIndex == index, + onSelect: { state.switchToTab(index) }, + onClose: { state.closeTab(at: index) } + ) + .onDrag { provider(for: url) } + .onDrop(of: [.plainText], isTargeted: isTargetedBinding) { providers in + handleDrop(providers, at: index) + } } + + private var isTargetedBinding: Binding { + Binding( + get: { dropTargetIndex == index }, + set: { dropTargetIndex = $0 ? index : nil } + ) + } + + private func provider(for url: URL) -> NSItemProvider { + NSItemProvider(object: url.absoluteString as NSString) + } + + /// Reorders tabs when another tab is dropped on `index`. The payload + /// is the dropped tab's URL; `moveTab` re-snapshots the active index, + /// so the displayed document never reloads. The provider callback can + /// arrive off the main actor, so the reorder hops to main first. + private func handleDrop(_ providers: [NSItemProvider], at index: Int) -> Bool { + guard let provider = providers.first else { return false } + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let string = object as? String, let url = URL(string: string) else { return } + DispatchQueue.main.async { + guard let from = state.tabURLs.firstIndex(of: url) else { return } + state.moveTab(from: from, to: index) + } + } + return true + } +} diff --git a/Sources/PDFReaderKit/Models/PDFReaderState.swift b/Sources/PDFReaderKit/Models/PDFReaderState.swift index 8a96cd1..acfabb6 100644 --- a/Sources/PDFReaderKit/Models/PDFReaderState.swift +++ b/Sources/PDFReaderKit/Models/PDFReaderState.swift @@ -435,6 +435,27 @@ public final class PDFReaderState: ObservableObject { return loadDocument(at: tabURLs[index]) } + /// Reorders tabs: the tab at `from` takes the position of the tab at + /// `to` (the target shifts right), mirroring browser drag-and-drop. + /// The active tab stays active — its index is recomputed from its URL + /// after the move, so the displayed document never reloads. Session + /// order persists via the `tabURLs` observer. + @discardableResult + public func moveTab(from: Int, to: Int) -> Bool { + guard tabURLs.count > 1, + tabURLs.indices.contains(from), + tabURLs.indices.contains(to), + from != to + else { return false } + let activeURL = tabURLs.indices.contains(activeTabIndex) ? tabURLs[activeTabIndex] : nil + let moved = tabURLs.remove(at: from) + tabURLs.insert(moved, at: to) + if let activeURL, let idx = tabURLs.firstIndex(of: activeURL), idx != activeTabIndex { + activeTabIndex = idx + } + return true + } + /// Closes the tab at `index`, activating a neighbor, or clears the /// view when the last tab closes. public func closeTab(at index: Int) { From 31da439c5efc6b7c46c379fd3fb799ae1d43c4d2 Mon Sep 17 00:00:00 2001 From: Marvelous Akpotu Date: Wed, 21 Oct 2026 15:34:27 +0100 Subject: [PATCH 2/6] feat: tabs slide live while dragging, order commits on release --- README.md | 6 +- Sources/PDFReader/Views/ContentView.swift | 197 +++++++++++++++++----- 2 files changed, 156 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 15b0832..92c1805 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,10 @@ Native macOS PDF reader built with SwiftUI + PDFKit as a Swift Package - **Reading modes** — layout menu, book mode, RTL, night background, presentation (full-screen). - **Tabs + session restore** — open multiple PDFs as tabs in the tab bar - (⌘T / + button); click to switch, × to close, drag onto another tab to - rearrange them (the active document never reloads while reordering). + (⌘T / + button); click to switch, × to close, drag a tab sideways and it + slides live under the pointer while the other tabs spring out of the way; + release to drop it in place (the active document never reloads while + reordering). Switching back to a tab returns to the exact section of the page you left (not just the page top); quitting and relaunching reopens the same tabs with each file back on the page you left it. diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index b7ca4a6..7b5b719 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -780,13 +780,10 @@ extension View { // MARK: - Tab Bar - -/// One tab: filename, click to switch, × to close. Also a drag source -/// (rearrange) and drop target for sibling tabs. +/// One tab: filename, click to switch, × to close. struct TabButton: View { let title: String let isActive: Bool - var isDropTarget: Bool = false let onSelect: () -> Void let onClose: () -> Void @@ -820,12 +817,8 @@ struct TabButton: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder( - isDropTarget ? Color.accentColor : (isActive ? Color.accentColor.opacity(0.5) : .clear), - style: StrokeStyle(lineWidth: 1.5, dash: isDropTarget ? [3, 3] : []) - ) + .strokeBorder(isActive ? Color.accentColor.opacity(0.5) : .clear, lineWidth: 1) ) - .opacity(isDropTarget ? 0.6 : 1) .contentShape(Rectangle()) .onTapGesture(perform: onSelect) .help(title) @@ -833,11 +826,35 @@ struct TabButton: View { } /// Adobe-style tab bar, left-aligned, with a New Tab (+) button. -/// Tabs are drag sources and drop targets: drop a tab onto another to -/// take its position (browser-style reorder). +/// Tabs rearrange by dragging: the dragged tab follows the pointer while +/// the other tabs spring out of the way slot by slot; the order commits +/// on release (browser-style). Local `DragGesture` instead of a system +/// drag session because only gestures can drive the mid-drag layout. struct TabBar: View { @ObservedObject var state: PDFReaderState - @State private var dropTargetIndex: Int? + + /// Slot geometry per tab index, measured in the tab bar's coordinate + /// space. Layout frames are unaffected by `.offset`, so these are the + /// resting positions even while tabs are visually displaced. + @State private var frames: [Int: CGRect] = [:] + /// In-flight drag: the source index, its current display slot (as + /// tabs slide aside) and the pointer translation. + @State private var dragging: DragInfo? + + private struct DragInfo { + let from: Int + var p: Int + var translation: CGFloat + + init(from: Int) { + self.from = from + self.p = from + self.translation = 0 + } + } + + /// Space between adjacent tabs; must match the HStack's `spacing`. + private let gap: CGFloat = 4 var body: some View { HStack(spacing: 4) { @@ -846,7 +863,19 @@ struct TabBar: View { state: state, url: url, index: index, - dropTargetIndex: $dropTargetIndex + isDragging: dragging?.from == index, + offsetX: offsetX(for: index) + ) + .modifier(DragLift(isDragging: dragging?.from == index)) + .zIndex(dragging?.from == index ? 10 : 0) + .gesture(dragGesture(for: index)) + .background( + GeometryReader { geo in + Color.clear.preference( + key: TabFrameKey.self, + value: [index: geo.frame(in: .named("tabBar"))] + ) + } ) } Button { @@ -862,12 +891,96 @@ struct TabBar: View { .keyboardShortcut("t", modifiers: .command) Spacer(minLength: 0) } + .coordinateSpace(name: "tabBar") + .onPreferenceChange(TabFrameKey.self) { frames = $0 } .padding(.horizontal, 8) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .background(state.appTheme.chromeBackground) } + /// Visual offset of tab `index`: the dragged tab follows the pointer + /// one to one; the others sit at their slot in the display order and + /// animate there (cranked by `DragLift`'s animation). + private func offsetX(for index: Int) -> CGFloat { + guard let frame = frames[index] else { return 0 } + if let drag = dragging, drag.from == index { + return drag.translation + } + let order = displayOrder(for: dragging) + guard let pos = order.firstIndex(of: index) else { return 0 } + return slotOrigin(pos, in: order) - frame.minX + } + + /// Order the tabs are shown in: identity, or identity with the + /// dragged tab moved to its current display slot. + private func displayOrder(for drag: DragInfo?) -> [Int] { + var order = Array(state.tabURLs.indices) + if let drag, drag.p != drag.from { + order.remove(at: drag.from) + order.insert(drag.from, at: min(drag.p, order.count)) + } + return order + } + + /// Left edge of display slot `position`, laying the tabs out in + /// `order` from the row's resting origin with their own widths. + private func slotOrigin(_ position: Int, in order: [Int]) -> CGFloat { + guard let anchor = frames[0]?.minX else { return 0 } + var x = anchor + for k in 0.. Int { + guard let frame = frames[drag.from] else { return drag.p } + let center = frame.minX + frame.width / 2 + drag.translation + let order = displayOrder(for: drag) + var crossed = 0 + for (pos, tab) in order.enumerated() where tab != drag.from { + let right = slotOrigin(pos, in: order) + (frames[tab]?.width ?? 0) + if center > right { crossed += 1 } + } + return crossed + } + + private func dragGesture(for index: Int) -> some Gesture { + DragGesture(minimumDistance: 10) + .onChanged { value in + if dragging == nil { + dragging = DragInfo(from: index) + } + guard var drag = dragging, drag.from == index, frames[index] != nil else { return } + drag.translation = value.translation.width + drag.p = displayedSlot(for: drag) + dragging = drag + } + .onEnded { _ in + guard let drag = dragging, drag.from == index else { + dragging = nil + return + } + if drag.p != drag.from { + // Commit first, then clear the drag state on the next + // runloop turn: with the order already final, the + // dragged tab springs from under the pointer into its + // committed slot, and the other tabs' offsets zero + // against the same final layout — no visual jump. + state.moveTab(from: drag.from, to: drag.p) + } + DispatchQueue.main.async { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + dragging = nil + } + } + } + } + private func openNewTab() { let panel = NSOpenPanel() panel.allowsMultipleSelection = false @@ -881,51 +994,45 @@ struct TabBar: View { } } -/// One tab plus its drag-and-drop wiring, split out of `TabBar` so the -/// modifier chain type-checks fast. +/// Elevated look for the tab being dragged: gentle magnification and a +/// drop shadow. The modifiers must be unconditional (values change, not +/// the modifier list) — a structural branch swap mid-gesture would cancel +/// the active drag. +private struct DragLift: ViewModifier { + let isDragging: Bool + + func body(content: Content) -> some View { + content + .scaleEffect(isDragging ? 1.05 : 1) + .shadow(color: .black.opacity(isDragging ? 0.35 : 0), radius: isDragging ? 6 : 0, y: 2) + } +} + +/// One tab plus its sliding offset, split out of `TabBar` so the modifier +/// chain type-checks fast. Slides with a spring unless it's the dragged +/// tab itself, which must track the pointer exactly. private struct TabItem: View { @ObservedObject var state: PDFReaderState let url: URL let index: Int - @Binding var dropTargetIndex: Int? + let isDragging: Bool + let offsetX: CGFloat var body: some View { TabButton( title: url.lastPathComponent, isActive: state.activeTabIndex == index, - isDropTarget: dropTargetIndex == index, onSelect: { state.switchToTab(index) }, onClose: { state.closeTab(at: index) } ) - .onDrag { provider(for: url) } - .onDrop(of: [.plainText], isTargeted: isTargetedBinding) { providers in - handleDrop(providers, at: index) - } } - - private var isTargetedBinding: Binding { - Binding( - get: { dropTargetIndex == index }, - set: { dropTargetIndex = $0 ? index : nil } - ) - } - - private func provider(for url: URL) -> NSItemProvider { - NSItemProvider(object: url.absoluteString as NSString) + .offset(x: offsetX) + .animation(isDragging ? nil : .spring(response: 0.25, dampingFraction: 0.85), value: offsetX) } +} - /// Reorders tabs when another tab is dropped on `index`. The payload - /// is the dropped tab's URL; `moveTab` re-snapshots the active index, - /// so the displayed document never reloads. The provider callback can - /// arrive off the main actor, so the reorder hops to main first. - private func handleDrop(_ providers: [NSItemProvider], at index: Int) -> Bool { - guard let provider = providers.first else { return false } - provider.loadObject(ofClass: NSString.self) { object, _ in - guard let string = object as? String, let url = URL(string: string) else { return } - DispatchQueue.main.async { - guard let from = state.tabURLs.firstIndex(of: url) else { return } - state.moveTab(from: from, to: index) - } - } - return true +private struct TabFrameKey: PreferenceKey { + static var defaultValue: [Int: CGRect] = [:] + static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { + value.merge(nextValue()) { _, new in new } } } From 24da68a681121c19d180b21b8e967c10389e2c7e Mon Sep 17 00:00:00 2001 From: Marvelous Akpotu Date: Wed, 21 Oct 2026 16:04:06 +0100 Subject: [PATCH 3/6] fix: re-anchor drag grab on commit so displaced tabs don't snap --- Sources/PDFReader/Views/ContentView.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index 7b5b719..a2ccbda 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -851,6 +851,12 @@ struct TabBar: View { self.p = from self.translation = 0 } + + init(from: Int, p: Int, translation: CGFloat) { + self.from = from + self.p = p + self.translation = translation + } } /// Space between adjacent tabs; must match the HStack's `spacing`. @@ -966,11 +972,15 @@ struct TabBar: View { return } if drag.p != drag.from { - // Commit first, then clear the drag state on the next - // runloop turn: with the order already final, the - // dragged tab springs from under the pointer into its - // committed slot, and the other tabs' offsets zero - // against the same final layout — no visual jump. + // Re-anchor the grab to the dropped tab's new index in + // the same batch as the reorder: otherwise the element + // that now occupies the old index would inherit the + // pointer offset for one paint and jerk back on the + // settle (the "snap to the left"). With the grab still + // under the pointer, only the dropped tab is offset; + // clearing the state (next runloop, animated) then + // springs just it into its committed slot. + dragging = DragInfo(from: drag.p, p: drag.p, translation: drag.translation) state.moveTab(from: drag.from, to: drag.p) } DispatchQueue.main.async { From 4c1233fbb480bef23f93adf22a22b58d5e9905d0 Mon Sep 17 00:00:00 2001 From: Marvelous Akpotu Date: Wed, 21 Oct 2026 16:20:22 +0100 Subject: [PATCH 4/6] fix: preserve dropped tab position across commit, settle stays short --- Sources/PDFReader/Views/ContentView.swift | 33 ++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index a2ccbda..ba59823 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -845,17 +845,24 @@ struct TabBar: View { let from: Int var p: Int var translation: CGFloat + /// Offset of the grabbed tab relative to its own layout frame. + /// Equals `translation` while `from` is unchanged; re-anchoring + /// the grab to a new slot after commit requires the frame delta + /// so the tab keeps its exact rendered position (no jump). + var grabDX: CGFloat init(from: Int) { self.from = from self.p = from self.translation = 0 + self.grabDX = 0 } - init(from: Int, p: Int, translation: CGFloat) { + init(from: Int, p: Int, translation: CGFloat, grabDX: CGFloat? = nil) { self.from = from self.p = p self.translation = translation + self.grabDX = grabDX ?? translation } } @@ -911,7 +918,7 @@ struct TabBar: View { private func offsetX(for index: Int) -> CGFloat { guard let frame = frames[index] else { return 0 } if let drag = dragging, drag.from == index { - return drag.translation + return drag.grabDX } let order = displayOrder(for: dragging) guard let pos = order.firstIndex(of: index) else { return 0 } @@ -973,14 +980,20 @@ struct TabBar: View { } if drag.p != drag.from { // Re-anchor the grab to the dropped tab's new index in - // the same batch as the reorder: otherwise the element - // that now occupies the old index would inherit the - // pointer offset for one paint and jerk back on the - // settle (the "snap to the left"). With the grab still - // under the pointer, only the dropped tab is offset; - // clearing the state (next runloop, animated) then - // springs just it into its committed slot. - dragging = DragInfo(from: drag.p, p: drag.p, translation: drag.translation) + // the same batch as the reorder, compensating for the + // frame change (translation + old frame − new frame): + // the tab renders exactly where it was, so it settles + // only the short distance from under the pointer into + // its committed slot instead of jumping and springing + // the whole translation back (the "snap to the left"). + // Clearing the state on the next runloop, animated, + // finishes that short settle. + dragging = DragInfo( + from: drag.p, + p: drag.p, + translation: drag.translation, + grabDX: drag.translation + (frames[drag.from]?.minX ?? 0) - (frames[drag.p]?.minX ?? 0) + ) state.moveTab(from: drag.from, to: drag.p) } DispatchQueue.main.async { From 9ec74583b1d05153c5d61e26672a6aa90cc64cb6 Mon Sep 17 00:00:00 2001 From: Marvelous Akpotu Date: Wed, 21 Oct 2026 16:50:32 +0100 Subject: [PATCH 5/6] fix: commit reorder at half-over so natural drops settle in place --- Sources/PDFReader/Views/ContentView.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index ba59823..97449c3 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -948,16 +948,18 @@ struct TabBar: View { } /// Display slot the dragged tab belongs in right now: every other - /// tab's slot in display order whose right edge the dragged tab's - /// center has crossed. + /// tab's slot in display order whose midpoint the dragged tab's + /// center has crossed. Committing at half-over (browser-style) + /// keeps the residual pointer-to-slot distance near zero, so the + /// drop needs almost no settle motion on a normal release. private func displayedSlot(for drag: DragInfo) -> Int { guard let frame = frames[drag.from] else { return drag.p } let center = frame.minX + frame.width / 2 + drag.translation let order = displayOrder(for: drag) var crossed = 0 for (pos, tab) in order.enumerated() where tab != drag.from { - let right = slotOrigin(pos, in: order) + (frames[tab]?.width ?? 0) - if center > right { crossed += 1 } + let midpoint = slotOrigin(pos, in: order) + (frames[tab]?.width ?? 0) / 2 + if center > midpoint { crossed += 1 } } return crossed } @@ -997,7 +999,7 @@ struct TabBar: View { state.moveTab(from: drag.from, to: drag.p) } DispatchQueue.main.async { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + withAnimation(.spring(response: 0.25, dampingFraction: 0.95)) { dragging = nil } } @@ -1049,7 +1051,7 @@ private struct TabItem: View { onClose: { state.closeTab(at: index) } ) .offset(x: offsetX) - .animation(isDragging ? nil : .spring(response: 0.25, dampingFraction: 0.85), value: offsetX) + .animation(isDragging ? nil : .spring(response: 0.25, dampingFraction: 0.95), value: offsetX) } } From 1076271da903124f5879fd6fc0857b05ccf12629 Mon Sep 17 00:00:00 2001 From: Marvelous Akpotu Date: Wed, 21 Oct 2026 17:07:31 +0100 Subject: [PATCH 6/6] fix: smooth tab drag (baseline threshold travel, pointer chase, softer slides) --- Sources/PDFReader/Views/ContentView.swift | 27 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index 97449c3..98d102d 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -850,12 +850,16 @@ struct TabBar: View { /// the grab to a new slot after commit requires the frame delta /// so the tab keeps its exact rendered position (no jump). var grabDX: CGFloat + /// Translation captured when tracking began, subtracted so the + /// threshold-distance pointer travel doesn't jump the tab. + var startAt: CGFloat - init(from: Int) { + init(from: Int, startAt: CGFloat = 0) { self.from = from self.p = from self.translation = 0 self.grabDX = 0 + self.startAt = startAt } init(from: Int, p: Int, translation: CGFloat, grabDX: CGFloat? = nil) { @@ -863,6 +867,7 @@ struct TabBar: View { self.p = p self.translation = translation self.grabDX = grabDX ?? translation + self.startAt = 0 } } @@ -968,10 +973,14 @@ struct TabBar: View { DragGesture(minimumDistance: 10) .onChanged { value in if dragging == nil { - dragging = DragInfo(from: index) + // Baseline the translation at gesture activation so + // the pointer travel needed to cross the threshold + // (which happens before tracking) isn't replayed as + // one jump when the tab starts following. + dragging = DragInfo(from: index, startAt: value.translation.width) } guard var drag = dragging, drag.from == index, frames[index] != nil else { return } - drag.translation = value.translation.width + drag.translation = value.translation.width - drag.startAt drag.p = displayedSlot(for: drag) dragging = drag } @@ -999,7 +1008,7 @@ struct TabBar: View { state.moveTab(from: drag.from, to: drag.p) } DispatchQueue.main.async { - withAnimation(.spring(response: 0.25, dampingFraction: 0.95)) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.95)) { dragging = nil } } @@ -1051,7 +1060,15 @@ private struct TabItem: View { onClose: { state.closeTab(at: index) } ) .offset(x: offsetX) - .animation(isDragging ? nil : .spring(response: 0.25, dampingFraction: 0.95), value: offsetX) + // Dragged: a short linear chase smooths the discrete mouse-event + // steps (pointer weight, Chrome-like). Resting: spring for slot + // slides and the drop settle — same curve everywhere reads smooth. + .animation( + isDragging + ? .linear(duration: 0.05) + : .spring(response: 0.3, dampingFraction: 0.95), + value: offsetX + ) } }