Quitar swipe entre charts + ventana del brush deslizable (build 80) #31 #32

Feedback TF build 79: fuera el swipe entre gráficas (navegación = chip bar;
el gesto chocaba con scrub/pan/brush). Brush con gesto unificado decidido en
touch-down: cerca de un asa (18pt) redimensiona; en cualquier otro punto
(dentro de la ventana o sobre el historial atenuado) desliza la ventana entera
manteniendo el ancho — seleccionar ene–mar y arrastrar hasta jun–ago. Con
ventanas estrechas las asas solo agarran desde fuera, el interior siempre mueve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
This commit is contained in:
alexandrev-tibco
2026-08-06 21:46:07 +02:00
parent f1aeafccf6
commit cf984dbf09
3 changed files with 69 additions and 77 deletions
+56 -38
View File
@@ -145,9 +145,16 @@ struct ChartRangeBrush: View {
@Binding var selection: ClosedRange<Int>
@State private var dragStartIndices: (lo: Int, hi: Int)?
@State private var dragTarget: DragTarget?
@State private var lastTickedIndex: Int = -1
@State private var showingPickers = false
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
/// One unified gesture instead of per-element gestures: with a narrow
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
/// window and moving it was impossible.
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
private static let labelFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
@@ -219,7 +226,7 @@ struct ChartRangeBrush: View {
.frame(width: max(0, width - xHi))
.offset(x: xHi)
// Window frame draggable as a whole to slide through history
// Window frame
RoundedRectangle(cornerRadius: 6)
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
.background(
@@ -227,15 +234,13 @@ struct ChartRangeBrush: View {
)
.frame(width: max(10, xHi - xLo))
.offset(x: xLo)
.contentShape(Rectangle())
.gesture(moveGesture(width: width))
// Handles
// Handles (visual only hit-testing lives in the unified gesture)
handle(at: xLo)
.gesture(handleGesture(isLower: true, width: width))
handle(at: xHi)
.gesture(handleGesture(isLower: false, width: width))
}
.contentShape(Rectangle())
.gesture(unifiedGesture(width: width))
}
private func handle(at x: CGFloat) -> some View {
@@ -245,9 +250,9 @@ struct ChartRangeBrush: View {
.overlay(
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
)
.frame(width: 30, height: 44) // generous hit area
.contentShape(Rectangle())
.offset(x: x - 15)
.frame(height: 44)
.offset(x: x - 2.5)
.allowsHitTesting(false)
}
// MARK: Geometry helpers
@@ -263,22 +268,41 @@ struct ChartRangeBrush: View {
return Int((frac * CGFloat(dates.count - 1)).rounded())
}
// MARK: Gestures
// MARK: Unified gesture
//
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
// anywhere else (inside the window OR on the dimmed history) slides the
// whole window keeping its width select JanMar, then swipe to land on
// JunAug. Narrow windows stay movable because the edge grip only wins in
// its 18pt zone (from outside when the window is too narrow to share).
private func handleGesture(isLower: Bool, width: CGFloat) -> some Gesture {
private func unifiedGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? clampedSelection
if dragStartIndices == nil { dragStartIndices = (lo, hi) }
let i = index(atX: value.location.x, width: width)
if dragStartIndices == nil {
dragStartIndices = (lo, hi)
dragTarget = target(
forX: value.startLocation.x,
xLo: xPosition(index: lo, width: width),
xHi: xPosition(index: hi, width: width)
)
}
guard let target = dragTarget else { return }
var newLo = lo
var newHi = hi
if isLower {
newLo = min(i, hi)
} else {
newHi = max(i, lo)
switch target {
case .lowerHandle:
newLo = min(index(atX: value.location.x, width: width), hi)
case .upperHandle:
newHi = max(index(atX: value.location.x, width: width), lo)
case .moveWindow:
let span = hi - lo
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
newHi = newLo + span
}
let ticked = isLower ? newLo : newHi
let ticked = (target == .upperHandle) ? newHi : newLo
if ticked != lastTickedIndex {
lastTickedIndex = ticked
ChartHaptics.tick()
@@ -287,30 +311,24 @@ struct ChartRangeBrush: View {
}
.onEnded { _ in
dragStartIndices = nil
dragTarget = nil
lastTickedIndex = -1
}
}
/// Dragging the window itself slides it through history keeping its width
/// e.g. fix a 1-month window and replay the portfolio month by month.
private func moveGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? clampedSelection
if dragStartIndices == nil { dragStartIndices = (lo, hi) }
let span = hi - lo
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
let newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
if newLo != lastTickedIndex {
lastTickedIndex = newLo
ChartHaptics.tick()
}
selection = newLo...(newLo + span)
}
.onEnded { _ in
dragStartIndices = nil
lastTickedIndex = -1
}
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
let grip: CGFloat = 18
let windowWidth = xHi - xLo
if windowWidth >= grip * 3 {
// Wide window: edges grab from either side of the handle.
if abs(x - xLo) <= grip { return .lowerHandle }
if abs(x - xHi) <= grip { return .upperHandle }
} else {
// Narrow window: the inside belongs to MOVE; edges only from outside.
if x < xLo && xLo - x <= grip { return .lowerHandle }
if x > xHi && x - xHi <= grip { return .upperHandle }
}
return .moveWindow
}
}