From e5f14bb72a919a9dbb7c68b766ffd857375be976 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 17 Jun 2026 18:07:20 +0100 Subject: [PATCH 1/4] Make resize handle size configurable --- Sources/PianoRoll/PianoRoll.swift | 14 ++++++++++++-- Sources/PianoRoll/PianoRollNoteView.swift | 9 ++++++++- Sources/PianoRoll/VerticalPianoRollNoteView.swift | 9 ++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/PianoRoll/PianoRoll.swift b/Sources/PianoRoll/PianoRoll.swift index 257f8ec..b04725b 100644 --- a/Sources/PianoRoll/PianoRoll.swift +++ b/Sources/PianoRoll/PianoRoll.swift @@ -22,6 +22,9 @@ public struct PianoRoll: View { var noteLineOpacity: Double var layout: PianoRollLayout var rowBackgroundColor: (Int) -> Color? + /// Length of the trailing drag handle used to resize a note (width in a horizontal + /// layout, height in a vertical one). `nil` keeps the default of half a grid column. + var resizeHandleLength: CGFloat? var noteContent: (PianoRollNote, Bool) -> NoteContent /// Initialize PianoRoll with a binding to a model, a color, and a custom note view builder @@ -34,6 +37,7 @@ public struct PianoRoll: View { /// - gridSize: Size of a grid cell /// - layout: Horizontal or vertical layout /// - rowBackgroundColor: Color for a pitch row, or nil to leave it transparent. The pitch is 1-based. + /// - resizeHandleLength: Length of the trailing drag handle used to resize a note. `nil` (default) uses half a grid column. /// - noteContent: Custom view builder for note appearance. Receives the note and whether it is active (hovering/dragging). public init( editable: Bool = true, @@ -44,6 +48,7 @@ public struct PianoRoll: View { gridSize: CGSize = CGSize(width: 80, height: 40), layout: PianoRollLayout = .horizontal, rowBackgroundColor: @escaping (Int) -> Color? = { _ in nil }, + resizeHandleLength: CGFloat? = nil, @ViewBuilder noteContent: @escaping (PianoRollNote, Bool) -> NoteContent ) { _model = model @@ -54,6 +59,7 @@ public struct PianoRoll: View { self.editable = editable self.layout = layout self.rowBackgroundColor = rowBackgroundColor + self.resizeHandleLength = resizeHandleLength self.noteContent = noteContent } @@ -112,6 +118,7 @@ public struct PianoRoll: View { sequenceHeight: model.height, isContinuous: true, editable: editable, + resizeHandleLength: resizeHandleLength, noteContent: noteContent ).onTapGesture { guard editable else { return } @@ -127,6 +134,7 @@ public struct PianoRoll: View { sequenceHeight: model.height, isContinuous: true, editable: editable, + resizeHandleLength: resizeHandleLength, noteContent: noteContent ).onTapGesture { guard editable else { return } @@ -149,7 +157,8 @@ extension PianoRoll where NoteContent == DefaultNoteView { gridColor: Color = Color(red: 15.0 / 255.0, green: 17.0 / 255.0, blue: 16.0 / 255.0), gridSize: CGSize = CGSize(width: 80, height: 40), layout: PianoRollLayout = .horizontal, - rowBackgroundColor: @escaping (Int) -> Color? = { _ in nil } + rowBackgroundColor: @escaping (Int) -> Color? = { _ in nil }, + resizeHandleLength: CGFloat? = nil ) { self.init( editable: editable, @@ -159,7 +168,8 @@ extension PianoRoll where NoteContent == DefaultNoteView { gridColor: gridColor, gridSize: gridSize, layout: layout, - rowBackgroundColor: rowBackgroundColor + rowBackgroundColor: rowBackgroundColor, + resizeHandleLength: resizeHandleLength ) { note, isActive in DefaultNoteView( note: note, diff --git a/Sources/PianoRoll/PianoRollNoteView.swift b/Sources/PianoRoll/PianoRollNoteView.swift index 8f83e22..40892c5 100644 --- a/Sources/PianoRoll/PianoRollNoteView.swift +++ b/Sources/PianoRoll/PianoRollNoteView.swift @@ -28,6 +28,9 @@ struct PianoRollNoteView: View { var sequenceHeight: Int var isContinuous = false var editable: Bool = false + /// Width of the trailing drag handle used to change a note's length. + /// `nil` keeps the default of half a grid column. + var resizeHandleLength: CGFloat? = nil var noteContent: (PianoRollNote, Bool) -> NoteContent var isActive: Bool { @@ -38,6 +41,10 @@ struct PianoRollNoteView: View { note.color ?? color } + private var lengthHandleWidth: CGFloat { + resizeHandleLength ?? gridSize.width * 0.5 + } + func snap(note: PianoRollNote, offset: CGSize, lengthOffset: CGFloat = 0.0) -> PianoRollNote { var n = note if isContinuous { @@ -124,7 +131,7 @@ struct PianoRollNoteView: View { Spacer() Rectangle() .foregroundColor(.white.opacity(0.001)) - .frame(width: gridSize.width * 0.5, height: gridSize.height) + .frame(width: lengthHandleWidth, height: gridSize.height) .gesture(editable ? lengthDragGesture : nil) } .frame(width: gridSize.width * CGFloat(note.length), diff --git a/Sources/PianoRoll/VerticalPianoRollNoteView.swift b/Sources/PianoRoll/VerticalPianoRollNoteView.swift index 5fb2229..2fd570a 100644 --- a/Sources/PianoRoll/VerticalPianoRollNoteView.swift +++ b/Sources/PianoRoll/VerticalPianoRollNoteView.swift @@ -28,6 +28,9 @@ struct VerticalPianoRollNoteView: View { var sequenceHeight: Int var isContinuous = false var editable: Bool = false + /// Height of the trailing drag handle used to change a note's length. + /// `nil` keeps the default of half a grid column. + var resizeHandleLength: CGFloat? = nil var noteContent: (PianoRollNote, Bool) -> NoteContent var isActive: Bool { @@ -38,6 +41,10 @@ struct VerticalPianoRollNoteView: View { note.color ?? color } + private var lengthHandleHeight: CGFloat { + resizeHandleLength ?? gridSize.width * 0.5 + } + func snap(note: PianoRollNote, offset: CGSize, lengthOffset: CGFloat = 0.0) -> PianoRollNote { var note = note if isContinuous { @@ -128,7 +135,7 @@ struct VerticalPianoRollNoteView: View { Spacer() Rectangle() .foregroundColor(.white.opacity(0.001)) - .frame(width: gridSize.height, height: gridSize.width * 0.5) + .frame(width: gridSize.height, height: lengthHandleHeight) .gesture(editable ? heightDragGesture : nil) } From c8045e5191e83506066ec5fa33731da032ac205a Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 1 Jul 2026 22:27:14 +0100 Subject: [PATCH 2/4] Rework mobile note interaction: single-commit drags, reliable taps - Note move drags now write the model only on gesture end. Previously every finger-move event wrote through the binding, which for host apps meant a full state round-trip per touch sample - on slower devices this caused frame drops mid-gesture and random tap/drag/scroll misclassification. - Add-note now uses SpatialTapGesture instead of the TapGesture().sequenced(before: DragGesture) hack, which silently failed when the finger drifted. Requires iOS 16 / macOS 13. - Note drag minimumDistance raised 2 -> 8 so tap-to-delete with slight finger movement is no longer misread as a micro-drag. - Note views use a single root ZStack with an always-present, opacity controlled drag preview, keeping a constant view count per ForEach element (previously any note change re-evaluated every note body). --- Package.swift | 4 +- Sources/PianoRoll/PianoRoll.swift | 7 +- Sources/PianoRoll/PianoRollNoteView.swift | 94 ++++++++-------- .../PianoRoll/VerticalPianoRollNoteView.swift | 100 +++++++++--------- 4 files changed, 100 insertions(+), 105 deletions(-) diff --git a/Package.swift b/Package.swift index b696e03..7e5d31d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version: 5.5 +// swift-tools-version: 5.7 import PackageDescription let package = Package( name: "PianoRoll", - platforms: [.macOS(.v12), .iOS(.v15)], + platforms: [.macOS(.v13), .iOS(.v16)], products: [.library(name: "PianoRoll", targets: ["PianoRoll"])], targets: [ .target(name: "PianoRoll", dependencies: []), diff --git a/Sources/PianoRoll/PianoRoll.swift b/Sources/PianoRoll/PianoRoll.swift index b04725b..5726750 100644 --- a/Sources/PianoRoll/PianoRoll.swift +++ b/Sources/PianoRoll/PianoRoll.swift @@ -74,7 +74,10 @@ public struct PianoRoll: View { /// SwiftUI view with grid and ability to add, delete and modify notes public var body: some View { ZStack(alignment: .topLeading) { - let dragGesture = DragGesture(minimumDistance: 0).onEnded { value in + // A plain spatial tap adds a note. The previous + // TapGesture().sequenced(before: DragGesture(minimumDistance: 0)) + // hack silently failed whenever the finger drifted a few points. + let addNoteGesture = SpatialTapGesture().onEnded { value in let location = value.location var note: PianoRollNote switch layout { @@ -106,7 +109,7 @@ public struct PianoRoll: View { .stroke(lineWidth: 0.5) .foregroundColor(gridColor) .contentShape(Rectangle()) - .gesture(editable ? TapGesture().sequenced(before: dragGesture) : nil) + .gesture(editable ? addNoteGesture : nil) ForEach($model.notes) { $note in switch layout { case .horizontal: diff --git a/Sources/PianoRoll/PianoRollNoteView.swift b/Sources/PianoRoll/PianoRollNoteView.swift index 40892c5..f03769c 100644 --- a/Sources/PianoRoll/PianoRollNoteView.swift +++ b/Sources/PianoRoll/PianoRollNoteView.swift @@ -16,7 +16,6 @@ struct PianoRollNoteView: View { // Note: using @GestureState instead of @State here fixes a bug where the // offset could get stuck when inside a ScrollView. @GestureState var offset = CGSize.zero - @GestureState var startNote: PianoRollNote? @State var hovering = false @@ -74,39 +73,23 @@ struct PianoRollNoteView: View { } var body: some View { - // While dragging, show where the note will go. - if offset != CGSize.zero { - Rectangle() - .foregroundColor(.black.opacity(0.2)) - .frame(width: gridSize.width * CGFloat(note.length), - height: gridSize.height) - .offset(noteOffset(note: note)) - .zIndex(-1) - } - - // Set the minimum distance so a note drag will override - // the drag of a containing ScrollView. - let minimumDistance: CGFloat = 2 - - // We don't want to actually update the data model until - // the drag is completed, so the entire drag is recorded - // as a single undo. + // The minimum distance a note drag needs before it starts: high enough + // that a tap-to-delete with slight finger movement isn't misread as a + // drag, while still overriding the drag of a containing ScrollView. + let minimumDistance: CGFloat = 8 + + // The model is only written when the drag completes, so the entire + // drag is a single model change (and a single undo step). While the + // drag is in flight the note is rendered from local gesture state. let noteDragGesture = DragGesture(minimumDistance: minimumDistance) .updating($offset) { value, state, _ in state = value.translation } - .updating($startNote){ value, state, _ in - if state == nil { - state = note - } - } - .onChanged { value in - if let startNote = startNote { - note = snap(note: startNote, offset: value.translation) - } + .onEnded { value in + note = snap(note: note, offset: value.translation) } - let lengthDragGesture = DragGesture(minimumDistance: minimumDistance) + let lengthDragGesture = DragGesture(minimumDistance: 2) .updating($lengthOffset) { value, state, _ in state = value.translation.width } @@ -114,28 +97,41 @@ struct PianoRollNoteView: View { note = snap(note: note, offset: CGSize.zero, lengthOffset: value.translation.width) } - // Main note body. - noteContent(note, isActive) - .onHover { over in hovering = over } - .padding(1) // so we can see consecutive notes - .frame(width: max(gridSize.width, gridSize.width * CGFloat(note.length) + lengthOffset), - height: gridSize.height) - .offset(noteOffset(note: startNote ?? note, dragOffset: offset)) - .gesture(editable ? noteDragGesture : nil) - .preference(key: NoteOffsetsKey.self, - value: [NoteOffsetInfo(offset: noteOffset(note: startNote ?? note, dragOffset: offset), - noteId: note.id)]) - - // Length tab at the end of the note. - HStack { - Spacer() + // Single root container so each ForEach element keeps a constant view + // count; conditional children here made SwiftUI re-evaluate every note + // body whenever any note changed. + ZStack(alignment: .topLeading) { + // While dragging, show where the note will land. Rectangle() - .foregroundColor(.white.opacity(0.001)) - .frame(width: lengthHandleWidth, height: gridSize.height) - .gesture(editable ? lengthDragGesture : nil) + .foregroundColor(.black.opacity(offset == .zero ? 0 : 0.2)) + .frame(width: gridSize.width * CGFloat(note.length), + height: gridSize.height) + .offset(noteOffset(note: snap(note: note, offset: offset))) + .zIndex(-1) + + // Main note body. + noteContent(note, isActive) + .onHover { over in hovering = over } + .padding(1) // so we can see consecutive notes + .frame(width: max(gridSize.width, gridSize.width * CGFloat(note.length) + lengthOffset), + height: gridSize.height) + .offset(noteOffset(note: note, dragOffset: offset)) + .gesture(editable ? noteDragGesture : nil) + .preference(key: NoteOffsetsKey.self, + value: [NoteOffsetInfo(offset: noteOffset(note: note, dragOffset: offset), + noteId: note.id)]) + + // Length tab at the end of the note. + HStack { + Spacer() + Rectangle() + .foregroundColor(.white.opacity(0.001)) + .frame(width: lengthHandleWidth, height: gridSize.height) + .gesture(editable ? lengthDragGesture : nil) + } + .frame(width: gridSize.width * CGFloat(note.length), + height: gridSize.height) + .offset(noteOffset(note: note, dragOffset: offset)) } - .frame(width: gridSize.width * CGFloat(note.length), - height: gridSize.height) - .offset(noteOffset(note: note, dragOffset: offset)) } } diff --git a/Sources/PianoRoll/VerticalPianoRollNoteView.swift b/Sources/PianoRoll/VerticalPianoRollNoteView.swift index 2fd570a..b1f7867 100644 --- a/Sources/PianoRoll/VerticalPianoRollNoteView.swift +++ b/Sources/PianoRoll/VerticalPianoRollNoteView.swift @@ -16,7 +16,6 @@ struct VerticalPianoRollNoteView: View { // Note: using @GestureState instead of @State here fixes a bug where the // offset could get stuck when inside a ScrollView. @GestureState var offset = CGSize.zero - @GestureState var startNote: PianoRollNote? @State var hovering = false @@ -75,42 +74,26 @@ struct VerticalPianoRollNoteView: View { } var body: some View { - // While dragging, show where the note will go. - if offset != CGSize.zero { - Rectangle() - .foregroundColor(.black.opacity(0.2)) - .frame(width: gridSize.height, - height: gridSize.width * CGFloat(note.length)) - .offset(noteOffset(note: note)) - .zIndex(-1) - } - - // Set the minimum distance so a note drag will override - // the drag of a containing ScrollView. - let minimumDistance: CGFloat = 2 - - // We don't want to actually update the data model until - // the drag is completed, so the entire drag is recorded - // as a single undo. + // The minimum distance a note drag needs before it starts: high enough + // that a tap-to-delete with slight finger movement isn't misread as a + // drag, while still overriding the drag of a containing ScrollView. + let minimumDistance: CGFloat = 8 + + // The model is only written when the drag completes, so the entire + // drag is a single model change (and a single undo step). While the + // drag is in flight the note is rendered from local gesture state. let noteDragGesture = DragGesture(minimumDistance: minimumDistance) .updating($offset) { value, state, _ in state = value.translation } - .updating($startNote) { _, state, _ in - if state == nil { - state = note - } - } - .onChanged { value in - if let startNote = startNote { - note = snap( - note: startNote, - offset: .init(width: -value.translation.width, height: -value.translation.height) - ) - } + .onEnded { value in + note = snap( + note: note, + offset: .init(width: -value.translation.width, height: -value.translation.height) + ) } - let heightDragGesture = DragGesture(minimumDistance: minimumDistance) + let heightDragGesture = DragGesture(minimumDistance: 2) .updating($heightOffset) { value, state, _ in state = value.translation.height } @@ -118,29 +101,42 @@ struct VerticalPianoRollNoteView: View { note = snap(note: note, offset: CGSize.zero, lengthOffset: value.translation.height) } - // Main note body. - noteContent(note, isActive) - .onHover { over in hovering = over } - .padding(1) // so we can see consecutive notes - .frame(width: gridSize.height, - height: max(gridSize.width, gridSize.width * CGFloat(note.length) + heightOffset)) - .offset(noteOffset(note: startNote ?? note, dragOffset: offset)) - .gesture(editable ? noteDragGesture : nil) - .preference(key: NoteOffsetsKey.self, - value: [NoteOffsetInfo(offset: noteOffset(note: startNote ?? note, dragOffset: offset), - noteId: note.id)]) - - // Length tab at the end of the note. - VStack { - Spacer() + // Single root container so each ForEach element keeps a constant view + // count; conditional children here made SwiftUI re-evaluate every note + // body whenever any note changed. + ZStack(alignment: .topLeading) { + // While dragging, show where the note will land. Rectangle() - .foregroundColor(.white.opacity(0.001)) - .frame(width: gridSize.height, height: lengthHandleHeight) - .gesture(editable ? heightDragGesture : nil) + .foregroundColor(.black.opacity(offset == .zero ? 0 : 0.2)) + .frame(width: gridSize.height, + height: gridSize.width * CGFloat(note.length)) + .offset(noteOffset(note: snap(note: note, offset: .init(width: -offset.width, height: -offset.height)))) + .zIndex(-1) + // Main note body. + noteContent(note, isActive) + .onHover { over in hovering = over } + .padding(1) // so we can see consecutive notes + .frame(width: gridSize.height, + height: max(gridSize.width, gridSize.width * CGFloat(note.length) + heightOffset)) + .offset(noteOffset(note: note, dragOffset: offset)) + .gesture(editable ? noteDragGesture : nil) + .preference(key: NoteOffsetsKey.self, + value: [NoteOffsetInfo(offset: noteOffset(note: note, dragOffset: offset), + noteId: note.id)]) + + // Length tab at the end of the note. + VStack { + Spacer() + Rectangle() + .foregroundColor(.white.opacity(0.001)) + .frame(width: gridSize.height, height: lengthHandleHeight) + .gesture(editable ? heightDragGesture : nil) + + } + .frame(width: gridSize.height, + height: gridSize.width * CGFloat(note.length)) + .offset(noteOffset(note: note, dragOffset: offset)) } - .frame(width: gridSize.height, - height: gridSize.width * CGFloat(note.length)) - .offset(noteOffset(note: note, dragOffset: offset)) } } From d393a0a3440d4299b9d10be19b87dd5213982a58 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 1 Jul 2026 22:28:18 +0100 Subject: [PATCH 3/4] Clamp resize handle to half the note length Keeps a grabbable move area on short notes when the host app requests a large (finger-sized) handle. --- Sources/PianoRoll/PianoRollNoteView.swift | 5 ++++- Sources/PianoRoll/VerticalPianoRollNoteView.swift | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/PianoRoll/PianoRollNoteView.swift b/Sources/PianoRoll/PianoRollNoteView.swift index f03769c..caa7f4d 100644 --- a/Sources/PianoRoll/PianoRollNoteView.swift +++ b/Sources/PianoRoll/PianoRollNoteView.swift @@ -41,7 +41,10 @@ struct PianoRollNoteView: View { } private var lengthHandleWidth: CGFloat { - resizeHandleLength ?? gridSize.width * 0.5 + // Never let the handle cover more than half the note, so short notes + // keep a grabbable area for moving. + min(resizeHandleLength ?? gridSize.width * 0.5, + gridSize.width * CGFloat(note.length) * 0.5) } func snap(note: PianoRollNote, offset: CGSize, lengthOffset: CGFloat = 0.0) -> PianoRollNote { diff --git a/Sources/PianoRoll/VerticalPianoRollNoteView.swift b/Sources/PianoRoll/VerticalPianoRollNoteView.swift index b1f7867..7d9fe8c 100644 --- a/Sources/PianoRoll/VerticalPianoRollNoteView.swift +++ b/Sources/PianoRoll/VerticalPianoRollNoteView.swift @@ -41,7 +41,10 @@ struct VerticalPianoRollNoteView: View { } private var lengthHandleHeight: CGFloat { - resizeHandleLength ?? gridSize.width * 0.5 + // Never let the handle cover more than half the note, so short notes + // keep a grabbable area for moving. + min(resizeHandleLength ?? gridSize.width * 0.5, + gridSize.width * CGFloat(note.length) * 0.5) } func snap(note: PianoRollNote, offset: CGSize, lengthOffset: CGFloat = 0.0) -> PianoRollNote { From e71ea66dc99c14c9d5656d8ee93441a894896d29 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 1 Jul 2026 22:34:50 +0100 Subject: [PATCH 4/4] Tidy comments: name the drag threshold, drop change narration --- Sources/PianoRoll/PianoRoll.swift | 3 -- Sources/PianoRoll/PianoRollNoteView.swift | 30 ++++++++----------- .../PianoRoll/VerticalPianoRollNoteView.swift | 30 ++++++++----------- 3 files changed, 26 insertions(+), 37 deletions(-) diff --git a/Sources/PianoRoll/PianoRoll.swift b/Sources/PianoRoll/PianoRoll.swift index 5726750..c816896 100644 --- a/Sources/PianoRoll/PianoRoll.swift +++ b/Sources/PianoRoll/PianoRoll.swift @@ -74,9 +74,6 @@ public struct PianoRoll: View { /// SwiftUI view with grid and ability to add, delete and modify notes public var body: some View { ZStack(alignment: .topLeading) { - // A plain spatial tap adds a note. The previous - // TapGesture().sequenced(before: DragGesture(minimumDistance: 0)) - // hack silently failed whenever the finger drifted a few points. let addNoteGesture = SpatialTapGesture().onEnded { value in let location = value.location var note: PianoRollNote diff --git a/Sources/PianoRoll/PianoRollNoteView.swift b/Sources/PianoRoll/PianoRollNoteView.swift index caa7f4d..7a505dc 100644 --- a/Sources/PianoRoll/PianoRollNoteView.swift +++ b/Sources/PianoRoll/PianoRollNoteView.swift @@ -41,10 +41,10 @@ struct PianoRollNoteView: View { } private var lengthHandleWidth: CGFloat { - // Never let the handle cover more than half the note, so short notes - // keep a grabbable area for moving. - min(resizeHandleLength ?? gridSize.width * 0.5, - gridSize.width * CGFloat(note.length) * 0.5) + let noteWidth = gridSize.width * CGFloat(note.length) + let requestedWidth = resizeHandleLength ?? gridSize.width * 0.5 + // The other half of the note stays grabbable for moving. + return min(requestedWidth, noteWidth * 0.5) } func snap(note: PianoRollNote, offset: CGSize, lengthOffset: CGFloat = 0.0) -> PianoRollNote { @@ -76,15 +76,12 @@ struct PianoRollNoteView: View { } var body: some View { - // The minimum distance a note drag needs before it starts: high enough - // that a tap-to-delete with slight finger movement isn't misread as a - // drag, while still overriding the drag of a containing ScrollView. - let minimumDistance: CGFloat = 8 - - // The model is only written when the drag completes, so the entire - // drag is a single model change (and a single undo step). While the - // drag is in flight the note is rendered from local gesture state. - let noteDragGesture = DragGesture(minimumDistance: minimumDistance) + // Below this distance a touch stays a tap (delete) or a scroll. + let dragActivationDistance: CGFloat = 8 + + // The drag renders from local gesture state and writes the model once, + // on end, so the whole drag is a single model change and undo step. + let noteDragGesture = DragGesture(minimumDistance: dragActivationDistance) .updating($offset) { value, state, _ in state = value.translation } @@ -100,11 +97,10 @@ struct PianoRollNoteView: View { note = snap(note: note, offset: CGSize.zero, lengthOffset: value.translation.width) } - // Single root container so each ForEach element keeps a constant view - // count; conditional children here made SwiftUI re-evaluate every note - // body whenever any note changed. + // Constant view count per note: a conditional child here makes SwiftUI + // re-evaluate every note body whenever any note changes. ZStack(alignment: .topLeading) { - // While dragging, show where the note will land. + // Drop-target preview while dragging. Rectangle() .foregroundColor(.black.opacity(offset == .zero ? 0 : 0.2)) .frame(width: gridSize.width * CGFloat(note.length), diff --git a/Sources/PianoRoll/VerticalPianoRollNoteView.swift b/Sources/PianoRoll/VerticalPianoRollNoteView.swift index 7d9fe8c..807bf22 100644 --- a/Sources/PianoRoll/VerticalPianoRollNoteView.swift +++ b/Sources/PianoRoll/VerticalPianoRollNoteView.swift @@ -41,10 +41,10 @@ struct VerticalPianoRollNoteView: View { } private var lengthHandleHeight: CGFloat { - // Never let the handle cover more than half the note, so short notes - // keep a grabbable area for moving. - min(resizeHandleLength ?? gridSize.width * 0.5, - gridSize.width * CGFloat(note.length) * 0.5) + let noteHeight = gridSize.width * CGFloat(note.length) + let requestedHeight = resizeHandleLength ?? gridSize.width * 0.5 + // The other half of the note stays grabbable for moving. + return min(requestedHeight, noteHeight * 0.5) } func snap(note: PianoRollNote, offset: CGSize, lengthOffset: CGFloat = 0.0) -> PianoRollNote { @@ -77,15 +77,12 @@ struct VerticalPianoRollNoteView: View { } var body: some View { - // The minimum distance a note drag needs before it starts: high enough - // that a tap-to-delete with slight finger movement isn't misread as a - // drag, while still overriding the drag of a containing ScrollView. - let minimumDistance: CGFloat = 8 - - // The model is only written when the drag completes, so the entire - // drag is a single model change (and a single undo step). While the - // drag is in flight the note is rendered from local gesture state. - let noteDragGesture = DragGesture(minimumDistance: minimumDistance) + // Below this distance a touch stays a tap (delete) or a scroll. + let dragActivationDistance: CGFloat = 8 + + // The drag renders from local gesture state and writes the model once, + // on end, so the whole drag is a single model change and undo step. + let noteDragGesture = DragGesture(minimumDistance: dragActivationDistance) .updating($offset) { value, state, _ in state = value.translation } @@ -104,11 +101,10 @@ struct VerticalPianoRollNoteView: View { note = snap(note: note, offset: CGSize.zero, lengthOffset: value.translation.height) } - // Single root container so each ForEach element keeps a constant view - // count; conditional children here made SwiftUI re-evaluate every note - // body whenever any note changed. + // Constant view count per note: a conditional child here makes SwiftUI + // re-evaluate every note body whenever any note changes. ZStack(alignment: .topLeading) { - // While dragging, show where the note will land. + // Drop-target preview while dragging. Rectangle() .foregroundColor(.black.opacity(offset == .zero ? 0 : 0.2)) .frame(width: gridSize.height,