Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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: []),
Expand Down
18 changes: 14 additions & 4 deletions Sources/PianoRoll/PianoRoll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public struct PianoRoll<NoteContent: View>: 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
Expand All @@ -34,6 +37,7 @@ public struct PianoRoll<NoteContent: View>: 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,
Expand All @@ -44,6 +48,7 @@ public struct PianoRoll<NoteContent: View>: 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
Expand All @@ -54,6 +59,7 @@ public struct PianoRoll<NoteContent: View>: View {
self.editable = editable
self.layout = layout
self.rowBackgroundColor = rowBackgroundColor
self.resizeHandleLength = resizeHandleLength
self.noteContent = noteContent
}

Expand All @@ -68,7 +74,7 @@ public struct PianoRoll<NoteContent: View>: 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 {
Expand Down Expand Up @@ -100,7 +106,7 @@ public struct PianoRoll<NoteContent: View>: 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:
Expand All @@ -112,6 +118,7 @@ public struct PianoRoll<NoteContent: View>: View {
sequenceHeight: model.height,
isContinuous: true,
editable: editable,
resizeHandleLength: resizeHandleLength,
noteContent: noteContent
).onTapGesture {
guard editable else { return }
Expand All @@ -127,6 +134,7 @@ public struct PianoRoll<NoteContent: View>: View {
sequenceHeight: model.height,
isContinuous: true,
editable: editable,
resizeHandleLength: resizeHandleLength,
noteContent: noteContent
).onTapGesture {
guard editable else { return }
Expand All @@ -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,
Expand All @@ -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,
Expand Down
100 changes: 51 additions & 49 deletions Sources/PianoRoll/PianoRollNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ struct PianoRollNoteView<NoteContent: View>: 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

Expand All @@ -28,6 +27,9 @@ struct PianoRollNoteView<NoteContent: View>: 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 {
Expand All @@ -38,6 +40,13 @@ struct PianoRollNoteView<NoteContent: View>: 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 {
Expand Down Expand Up @@ -67,68 +76,61 @@ struct PianoRollNoteView<NoteContent: View>: 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
}
.onEnded { value in
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))
}
}
106 changes: 54 additions & 52 deletions Sources/PianoRoll/VerticalPianoRollNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ struct VerticalPianoRollNoteView<NoteContent: View>: 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

Expand All @@ -28,6 +27,9 @@ struct VerticalPianoRollNoteView<NoteContent: View>: 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 {
Expand All @@ -38,6 +40,13 @@ struct VerticalPianoRollNoteView<NoteContent: View>: 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 {
Expand Down Expand Up @@ -68,72 +77,65 @@ struct VerticalPianoRollNoteView<NoteContent: View>: 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
}
.onEnded { value in
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))
}
}
Loading