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
1 change: 1 addition & 0 deletions Demo/Shared/PianoRollDemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct PianoRollDemoApp: App {
var body: some Scene {
WindowGroup {
PianoRollDemoView()
.frame(minWidth: 600, minHeight: 500)
}
}
}
58 changes: 54 additions & 4 deletions Demo/Shared/PianoRollDemoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,62 @@ public struct PianoRollDemoView: View {
@State var model = PianoRollModel(notes: [
PianoRollNote(start: 1, length: 2, pitch: 3),
PianoRollNote(start: 5, length: 1, pitch: 4),
], length: 128, height: 128)
], length: 16, height: 16)

static let pitchColors: [Color] = [
.red, .orange, .yellow, .green, .mint, .cyan,
.blue, .indigo, .purple, .pink, .red, .orange,
.yellow, .green, .mint, .cyan,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

]

@State var customModel = PianoRollModel(notes: [
PianoRollNote(start: 0, length: 2, pitch: 3, text: "C"),
PianoRollNote(start: 2, length: 2, pitch: 5, text: "E"),
PianoRollNote(start: 4, length: 3, pitch: 7, text: "G"),
PianoRollNote(start: 4, length: 1, pitch: 10, text: "B♭"),
PianoRollNote(start: 7, length: 1, pitch: 8, text: "A♭"),
PianoRollNote(start: 8, length: 4, pitch: 5, text: "E"),
PianoRollNote(start: 8, length: 2, pitch: 12, text: "D"),
PianoRollNote(start: 10, length: 1, pitch: 9, text: "A"),
PianoRollNote(start: 12, length: 3, pitch: 6, text: "F"),
PianoRollNote(start: 12, length: 1, pitch: 14, text: "F"),
PianoRollNote(start: 13, length: 2, pitch: 11, text: "C♯"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

], length: 16, height: 16)

public var body: some View {
ScrollView([.horizontal, .vertical], showsIndicators: true) {
PianoRoll(model: $model, noteColor: .cyan, layout: .vertical)
}.background(Color(white: 0.1))
VStack(spacing: 20) {
Text("Default Note Style")
ScrollView([.horizontal, .vertical], showsIndicators: true) {
PianoRoll(model: $model, noteColor: .cyan, layout: .horizontal)
}.background(Color(white: 0.4))

Text("Custom Neon Pill Notes")
ScrollView([.horizontal, .vertical], showsIndicators: true) {
PianoRoll(model: $customModel, noteColor: .cyan, layout: .horizontal) { note, isActive in
let color = Self.pitchColors[(note.pitch - 1) % Self.pitchColors.count]
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(
LinearGradient(
colors: [color, color.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
)
.opacity(isActive ? 1.0 : 0.85)
.shadow(color: color.opacity(isActive ? 0.9 : 0.5), radius: isActive ? 8 : 4)
RoundedRectangle(cornerRadius: 12)
.strokeBorder(.white.opacity(0.4), lineWidth: 1)
Text(note.text ?? "")
.font(.system(.caption, design: .rounded).bold())
.foregroundColor(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
}
.scaleEffect(isActive ? 1.05 : 1.0)
.animation(.easeOut(duration: 0.15), value: isActive)
}
}.background(Color(white: 0.12))
}
}
}

Expand Down
47 changes: 47 additions & 0 deletions Sources/PianoRoll/DefaultNoteView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/PianoRoll/

import SwiftUI

/// The default note appearance used by PianoRoll when no custom noteContent closure is provided.
public struct DefaultNoteView: View {
var note: PianoRollNote
var color: Color
var isActive: Bool
var lineOpacity: Double
var layout: PianoRollLayout

public var body: some View {
switch layout {
case .horizontal:
ZStack(alignment: .trailing) {
ZStack(alignment: .leading) {
Rectangle()
.foregroundColor((note.color ?? color).opacity(isActive ? 1.0 : 0.8))
Text(note.text ?? "")
.opacity(note.text == nil ? 0 : 1)
.padding(.leading, 5)
}
Rectangle()
.foregroundColor(.black)
.padding(4)
.frame(width: 10)
.opacity(lineOpacity)
}
case .vertical:
ZStack(alignment: .bottom) {
ZStack(alignment: .bottom) {
Rectangle()
.foregroundColor((note.color ?? color).opacity(isActive ? 1.0 : 0.8))
Text(note.text ?? "")
.opacity(note.text == nil ? 0 : 1)
.padding(.bottom, 5)
}
Rectangle()
.foregroundColor(.black)
.padding(4)
.frame(height: 10)
.opacity(lineOpacity)
}
}
}
}
59 changes: 49 additions & 10 deletions Sources/PianoRoll/PianoRoll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,46 @@

import SwiftUI

/// Layout orientation for the piano roll.
public enum PianoRollLayout: Sendable {
case horizontal
case vertical
}

/// Touch-oriented piano roll.
///
/// Note: Requires macOS 12 / iOS 15 due to SwiftUI bug (crashes in SwiftUI when deleting notes).
public struct PianoRoll: View {
public enum Layout: Sendable {
case horizontal
case vertical
}
public struct PianoRoll<NoteContent: View>: View {
public typealias Layout = PianoRollLayout

@Binding var model: PianoRollModel
var editable: Bool
var gridColor: Color
var gridSize: CGSize
var noteColor: Color
var noteLineOpacity: Double
var layout: Layout
var layout: PianoRollLayout
var noteContent: (PianoRollNote, Bool) -> NoteContent

/// Initialize PianoRoll with a binding to a model and a color
/// Initialize PianoRoll with a binding to a model, a color, and a custom note view builder
/// - Parameters:
/// - editable: Disable edition of any note in piano roll
/// - model: PianoRoll data
/// - noteColor: Color to use for the note indicator, defaults to system accent color
/// - noteLineOpacity: Opacity of the note view vertical black line
/// - gridColor: Color of grid
/// - gridSize: Size of a grid cell
/// - layout: Horizontal or vertical layout
/// - noteContent: Custom view builder for note appearance. Receives the note and whether it is active (hovering/dragging).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line Length Violation: Line should be 120 characters or less: currently 129 characters (line_length)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line Length Violation: Line should be 120 characters or less: currently 129 characters (line_length)

public init(
editable: Bool = true,
model: Binding<PianoRollModel>,
noteColor: Color = .accentColor,
noteLineOpacity: Double = 1,
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: Layout = .horizontal
layout: PianoRollLayout = .horizontal,
@ViewBuilder noteContent: @escaping (PianoRollNote, Bool) -> NoteContent
) {
_model = model
self.noteColor = noteColor
Expand All @@ -43,6 +50,7 @@ public struct PianoRoll: View {
self.gridColor = gridColor
self.editable = editable
self.layout = layout
self.noteContent = noteContent
}

private var width: CGFloat {
Expand Down Expand Up @@ -92,7 +100,7 @@ public struct PianoRoll: View {
sequenceHeight: model.height,
isContinuous: true,
editable: editable,
lineOpacity: noteLineOpacity
noteContent: noteContent
).onTapGesture {
guard editable else { return }
model.notes.removeAll(where: { $0 == note })
Expand All @@ -107,7 +115,7 @@ public struct PianoRoll: View {
sequenceHeight: model.height,
isContinuous: true,
editable: editable,
lineOpacity: noteLineOpacity
noteContent: noteContent
).onTapGesture {
guard editable else { return }
model.notes.removeAll(where: { $0 == note })
Expand All @@ -119,6 +127,37 @@ public struct PianoRoll: View {
}
}

/// Backward-compatible initializer that uses the default note appearance.
extension PianoRoll where NoteContent == DefaultNoteView {
public init(
editable: Bool = true,
model: Binding<PianoRollModel>,
noteColor: Color = .accentColor,
noteLineOpacity: Double = 1,
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
) {
self.init(
editable: editable,
model: model,
noteColor: noteColor,
noteLineOpacity: noteLineOpacity,
gridColor: gridColor,
gridSize: gridSize,
layout: layout
) { note, isActive in
DefaultNoteView(
note: note,
color: noteColor,
isActive: isActive,
lineOpacity: editable ? noteLineOpacity : 0,
layout: layout
)
}
}
}

struct PianoRollPreview: View {
init() {}

Expand Down
2 changes: 1 addition & 1 deletion Sources/PianoRoll/PianoRollGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct PianoRollGrid: Shape {
var gridSize: CGSize
var length: Int
var height: Int
var layout: PianoRoll.Layout
var layout: PianoRollLayout

func path(in rect: CGRect) -> Path {
let size = rect.size
Expand Down
23 changes: 7 additions & 16 deletions Sources/PianoRoll/PianoRollNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import SwiftUI
///
/// With each note as a separate view this might not be suitable for very large sequences, but
/// it makes it easier to implement.
struct PianoRollNoteView: View {
struct PianoRollNoteView<NoteContent: View>: View {
@Binding var note: PianoRollNote
var gridSize: CGSize
var color: Color
Expand All @@ -28,7 +28,11 @@ struct PianoRollNoteView: View {
var sequenceHeight: Int
var isContinuous = false
var editable: Bool = false
var lineOpacity: Double = 1
var noteContent: (PianoRollNote, Bool) -> NoteContent

var isActive: Bool {
hovering || offset != .zero || lengthOffset != 0
}

var noteColor: Color {
note.color ?? color
Expand Down Expand Up @@ -104,20 +108,7 @@ struct PianoRollNoteView: View {
}

// Main note body.
ZStack(alignment: .trailing) {
ZStack(alignment: .leading) {
Rectangle()
.foregroundColor(noteColor.opacity((hovering || offset != .zero || lengthOffset != 0) ? 1.0 : 0.8))
Text(note.text ?? "")
.opacity(note.text == nil ? 0 : 1)
.padding(.leading, 5)
}
Rectangle()
.foregroundColor(.black)
.padding(4)
.frame(width: 10)
.opacity(editable ? lineOpacity : 0)
}
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),
Expand Down
23 changes: 7 additions & 16 deletions Sources/PianoRoll/VerticalPianoRollNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import SwiftUI
///
/// With each note as a separate view this might not be suitable for very large sequences, but
/// it makes it easier to implement.
struct VerticalPianoRollNoteView: View {
struct VerticalPianoRollNoteView<NoteContent: View>: View {
@Binding var note: PianoRollNote
var gridSize: CGSize
var color: Color
Expand All @@ -28,7 +28,11 @@ struct VerticalPianoRollNoteView: View {
var sequenceHeight: Int
var isContinuous = false
var editable: Bool = false
var lineOpacity: Double = 1
var noteContent: (PianoRollNote, Bool) -> NoteContent

var isActive: Bool {
hovering || offset != .zero || heightOffset != 0
}

var noteColor: Color {
note.color ?? color
Expand Down Expand Up @@ -108,20 +112,7 @@ struct VerticalPianoRollNoteView: View {
}

// Main note body.
ZStack(alignment: .bottom) {
ZStack(alignment: .bottom) {
Rectangle()
.foregroundColor(noteColor.opacity((hovering || offset != .zero || heightOffset != 0) ? 1.0 : 0.8))
Text(note.text ?? "")
.opacity(note.text == nil ? 0 : 1)
.padding(.bottom, 5)
}
Rectangle()
.foregroundColor(.black)
.padding(4)
.frame(height: 10)
.opacity(editable ? lineOpacity : 0)
}
noteContent(note, isActive)
.onHover { over in hovering = over }
.padding(1) // so we can see consecutive notes
.frame(width: gridSize.height,
Expand Down
Loading