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 257f8ec..c816896 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 } @@ -68,7 +74,7 @@ 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 + let addNoteGesture = SpatialTapGesture().onEnded { value in let location = value.location var note: PianoRollNote switch layout { @@ -100,7 +106,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: @@ -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..7a505dc 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 @@ -28,6 +27,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 +40,13 @@ struct PianoRollNoteView: View { note.color ?? color } + private var lengthHandleWidth: CGFloat { + 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 { var n = note if isContinuous { @@ -67,39 +76,20 @@ 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) - } + // Below this distance a touch stays a tap (delete) or a scroll. + let dragActivationDistance: CGFloat = 8 - // 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. - let noteDragGesture = DragGesture(minimumDistance: minimumDistance) + // 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 } - .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 } @@ -107,28 +97,40 @@ 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() + // Constant view count per note: a conditional child here makes SwiftUI + // re-evaluate every note body whenever any note changes. + ZStack(alignment: .topLeading) { + // Drop-target preview while dragging. Rectangle() - .foregroundColor(.white.opacity(0.001)) - .frame(width: gridSize.width * 0.5, 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 5fb2229..807bf22 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 @@ -28,6 +27,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 +40,13 @@ struct VerticalPianoRollNoteView: View { note.color ?? color } + private var lengthHandleHeight: CGFloat { + 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 { var note = note if isContinuous { @@ -68,42 +77,23 @@ 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) - } + // Below this distance a touch stays a tap (delete) or a scroll. + let dragActivationDistance: CGFloat = 8 - // 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. - let noteDragGesture = DragGesture(minimumDistance: minimumDistance) + // 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 } - .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 } @@ -111,29 +101,41 @@ 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() + // Constant view count per note: a conditional child here makes SwiftUI + // re-evaluate every note body whenever any note changes. + ZStack(alignment: .topLeading) { + // Drop-target preview while dragging. Rectangle() - .foregroundColor(.white.opacity(0.001)) - .frame(width: gridSize.height, height: gridSize.width * 0.5) - .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)) } }