feat: add color picker

This commit is contained in:
dscyrescotti
2024-05-18 17:54:18 +07:00
parent ce3a021569
commit 0c03abee4e
17 changed files with 378 additions and 34 deletions

View File

@@ -81,6 +81,8 @@
ECFA15242BEF223300455818 /* GraphicContextObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFA15232BEF223300455818 /* GraphicContextObject.swift */; };
ECFA15262BEF224900455818 /* StrokeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFA15252BEF224900455818 /* StrokeObject.swift */; };
ECFA15282BEF225000455818 /* QuadObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFA15272BEF225000455818 /* QuadObject.swift */; };
ECFC51272BF8885700D0D051 /* ColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFC51262BF8885700D0D051 /* ColorPicker.swift */; };
ECFC512A2BF8BBD800D0D051 /* Triangle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFC51292BF8BBD800D0D051 /* Triangle.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -160,6 +162,8 @@
ECFA15232BEF223300455818 /* GraphicContextObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphicContextObject.swift; sourceTree = "<group>"; };
ECFA15252BEF224900455818 /* StrokeObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeObject.swift; sourceTree = "<group>"; };
ECFA15272BEF225000455818 /* QuadObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuadObject.swift; sourceTree = "<group>"; };
ECFC51262BF8885700D0D051 /* ColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPicker.swift; sourceTree = "<group>"; };
ECFC51292BF8BBD800D0D051 /* Triangle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Triangle.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -225,6 +229,7 @@
EC50500A2BF6672000B4D86E /* Components */ = {
isa = PBXGroup;
children = (
ECFC51282BF8BBD000D0D051 /* Shapes */,
EC50500B2BF6673300B4D86E /* ViewModifiers */,
);
path = Components;
@@ -315,6 +320,7 @@
ECA7387B2BE5EF3500A4542E /* Memo */ = {
isa = PBXGroup;
children = (
ECFC51252BF8885000D0D051 /* ColorPicker */,
EC5050082BF65D0500B4D86E /* Memo */,
EC5050052BF65CCD00B4D86E /* PenDock */,
);
@@ -599,6 +605,22 @@
path = Objects;
sourceTree = "<group>";
};
ECFC51252BF8885000D0D051 /* ColorPicker */ = {
isa = PBXGroup;
children = (
ECFC51262BF8885700D0D051 /* ColorPicker.swift */,
);
path = ColorPicker;
sourceTree = "<group>";
};
ECFC51282BF8BBD000D0D051 /* Shapes */ = {
isa = PBXGroup;
children = (
ECFC51292BF8BBD800D0D051 /* Triangle.swift */,
);
path = Shapes;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -672,6 +694,7 @@
ECA738B02BE60D0B00A4542E /* CanvasViewController.swift in Sources */,
ECA738E42BE6110800A4542E /* Drawable.swift in Sources */,
ECA738AD2BE60CC600A4542E /* DrawingView.swift in Sources */,
ECFC512A2BF8BBD800D0D051 /* Triangle.swift in Sources */,
ECA738E02BE610B900A4542E /* EraserRenderPass.swift in Sources */,
EC35655A2BF060D900A4E0BF /* Quad.metal in Sources */,
ECA738912BE600F500A4542E /* Cache.metal in Sources */,
@@ -704,6 +727,7 @@
ECA738CB2BE60F1900A4542E /* ViewPortContext.swift in Sources */,
ECA738EE2BE6125D00A4542E /* simd_float4x4++.swift in Sources */,
ECA7388C2BE6009600A4542E /* Textures.swift in Sources */,
ECFC51272BF8885700D0D051 /* ColorPicker.swift in Sources */,
ECA738B82BE60DDC00A4542E /* HistoryEvent.swift in Sources */,
EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */,
ECA738952BE6012D00A4542E /* ViewPort.metal in Sources */,

View File

@@ -117,7 +117,7 @@ extension GraphicContext {
func beginStroke(at point: CGPoint, pen: Pen) -> Stroke {
let stroke = Stroke(
bounds: [point.x - pen.thickness, point.y - pen.thickness, point.x + pen.thickness, point.y + pen.thickness],
color: pen.color,
color: pen.rgba,
style: pen.strokeStyle.rawValue,
createdAt: .now,
thickness: pen.thickness

View File

@@ -16,6 +16,7 @@ public class Tool: NSObject, ObservableObject {
@Published var pens: [Pen] = []
@Published var selectedPen: Pen?
@Published var draggedPen: Pen?
@Published var opensColorPicker: Bool = false
let scrollPublisher = PassthroughSubject<String, Never>()
@@ -32,6 +33,7 @@ public class Tool: NSObject, ObservableObject {
}
if let selectedPen = pens.first(where: { $0.isSelected }) {
selectPen(selectedPen)
scrollPublisher.send(selectedPen.id)
}
}
}

View File

@@ -18,9 +18,9 @@ class Pen: NSObject, ObservableObject, Identifiable {
object?.style = strokeStyle.rawValue
}
}
@Published var color: [CGFloat] {
@Published var rgba: [CGFloat] {
didSet {
object?.color = color
object?.color = rgba
}
}
@Published var thickness: CGFloat {
@@ -33,12 +33,18 @@ class Pen: NSObject, ObservableObject, Identifiable {
object?.isSelected = isSelected
}
}
var color: Color {
get { Color.rgba(from: rgba) }
set {
rgba = newValue.components
}
}
init(object: PenObject) {
self.object = object
self.id = object.objectID.uriRepresentation().absoluteString
self.style = (Stroke.Style(rawValue: object.style) ?? .marker).anyPenStyle
self.color = object.color
self.rgba = object.color
self.thickness = object.thickness
self.isSelected = object.isSelected
super.init()

View File

@@ -0,0 +1,20 @@
//
// Triangle.swift
// Memola
//
// Created by Dscyre Scotti on 5/18/24.
//
import SwiftUI
import Foundation
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
return path
}
}

View File

@@ -18,6 +18,18 @@ extension Color {
}
}
extension Color {
var hsba: (hue: Double, saturation: Double, brightness: Double, alpha: Double) {
let uiColor = UIColor(self)
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
return (hue, saturation, brightness, alpha)
}
}
extension UIColor {
var components: [CGFloat] {
let uiColor: UIColor = self

View File

@@ -0,0 +1,184 @@
//
// ColorPicker.swift
// Memola
//
// Created by Dscyre Scotti on 5/18/24.
//
import SwiftUI
import Foundation
struct ColorPicker: View {
@ObservedObject var pen: Pen
@State var hue: Double = 1
@State var saturation: Double = 0
@State var brightness: Double = 1
@State var alpha: Double = 1
let size: CGFloat = 15
var body: some View {
VStack(spacing: 10) {
colorPicker
.frame(width: 180, height: 180)
HStack(spacing: 10) {
Color(hue: hue, saturation: saturation, brightness: brightness)
.overlay {
Image("transparent-grid-square")
.resizable()
.scaleEffect(1.8)
.aspectRatio(contentMode: .fill)
.opacity(0.5)
.overlay {
pen.color
}
.clipShape(Triangle())
}
.frame(width: size * 2 + 10)
.cornerRadius(5)
VStack(spacing: 10) {
hueSlider
alphaSlider
}
}
}
.padding(10)
.background {
Rectangle()
.fill(.regularMaterial)
.ignoresSafeArea(.all)
}
.onAppear {
let hsba = pen.color.hsba
hue = hsba.hue
saturation = hsba.saturation
brightness = hsba.brightness
alpha = hsba.alpha * 1.43 - 0.43
}
}
@ViewBuilder
var colorPicker: some View {
GeometryReader { proxy in
ZStack {
Color(hue: hue, saturation: 1, brightness: 1)
LinearGradient(
colors: [.white, .clear],
startPoint: .leading,
endPoint: .trailing
)
LinearGradient(
colors: [.black, .clear],
startPoint: .bottom,
endPoint: .top
)
}
.cornerRadius(5)
.drawingGroup()
.overlay(alignment: .bottomLeading) {
Color(hue: hue, saturation: saturation, brightness: brightness)
.frame(width: size, height: size)
.clipShape(Circle())
.overlay {
Circle()
.strokeBorder(.white, lineWidth: 2)
}
.offset(x: -size + 5, y: size - 5)
.offset(x: max(proxy.size.width * saturation, size - 10), y: min(proxy.size.height * -brightness, -size + 10))
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
saturation = min(1, max(value.location.x / proxy.size.width, 0))
brightness = 1 - min(1, max(value.location.y / proxy.size.height, 0))
updateBaseColor()
}
)
}
}
@ViewBuilder
var hueSlider: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
LinearGradient(
colors: (0...10).map { Color(hue: Double($0) * 0.1, saturation: 1, brightness: 1) },
startPoint: .leading,
endPoint: .trailing
)
Color(hue: hue, saturation: 1, brightness: 1)
.frame(width: size, height: size)
.overlay {
Circle()
.strokeBorder(.white, lineWidth: 2)
}
.clipShape(Circle())
.offset(x: -size)
.offset(x: max(size, proxy.size.width * hue))
}
.frame(width: proxy.size.width, height: proxy.size.height)
.clipShape(Capsule())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
hue = min(1, max(value.location.x / proxy.size.width, 0))
updateBaseColor()
}
.onEnded { value in
hue = min(1, max(value.location.x / proxy.size.width, 0))
updateBaseColor()
}
)
}
.frame(height: size)
}
@ViewBuilder
var alphaSlider: some View {
GeometryReader { proxy in
let color = Color(hue: hue, saturation: saturation, brightness: brightness)
ZStack(alignment: .leading) {
LinearGradient(
colors: (3...10).map { color.opacity(0.1 * CGFloat($0)) },
startPoint: .leading,
endPoint: .trailing
)
.background {
Image("transparent-grid-rect")
.resizable()
.aspectRatio(contentMode: .fill)
.opacity(0.5)
.background(.white)
}
color
.frame(width: size, height: size)
.overlay {
Circle()
.strokeBorder(.white, lineWidth: 2)
}
.clipShape(Circle())
.offset(x: -size)
.offset(x: max(size, proxy.size.width * alpha))
}
.frame(width: proxy.size.width, height: proxy.size.height)
.clipShape(Capsule())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
alpha = min(1, max(value.location.x / proxy.size.width, 0))
updateBaseColor()
}
.onEnded { value in
alpha = min(1, max(value.location.x / proxy.size.width, 0))
updateBaseColor()
}
)
}
.frame(height: size)
}
func updateBaseColor() {
pen.color = Color(hue: hue, saturation: saturation, brightness: brightness).opacity(0.7 * alpha + 0.3)
}
}

View File

@@ -15,41 +15,85 @@ struct PenDockView: View {
let factor: CGFloat = 0.95
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(tool.pens) { pen in
penView(pen)
.id(pen.id)
.scrollTransition { content, phase in
content
.scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .trailing)
VStack(alignment: .trailing) {
if let pen = tool.selectedPen {
VStack(spacing: 10) {
Button {
tool.opensColorPicker = true
} label: {
let hsba = pen.color.hsba
Color(hue: hsba.hue, saturation: hsba.saturation, brightness: hsba.brightness)
.overlay {
Image("transparent-grid-square")
.resizable()
.scaleEffect(1.8)
.aspectRatio(contentMode: .fill)
.opacity(0.5)
.overlay {
pen.color
}
.clipShape(Triangle())
}
.clipShape(Capsule())
.frame(height: 20)
.drawingGroup()
}
.hoverEffect(.lift)
.popover(isPresented: $tool.opensColorPicker) {
ColorPicker(pen: pen)
.presentationCompactAdaptation(.popover)
}
Capsule()
.frame(height: 20)
}
.padding(.vertical, 10)
.padding(.leading, 40)
.padding()
.frame(width: width * factor - 18)
.background {
RoundedRectangle(cornerRadius: 20)
.fill(.regularMaterial)
}
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
Color.clear
.frame(width: width * factor - 18, height: 50)
}
.onReceive(tool.scrollPublisher) { id in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
proxy.scrollTo(id)
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(tool.pens) { pen in
penView(pen)
.id(pen.id)
.scrollTransition { content, phase in
content
.scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .trailing)
}
}
}
.padding(.vertical, 10)
.padding(.leading, 40)
}
.onReceive(tool.scrollPublisher) { id in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
proxy.scrollTo(id)
}
}
}
}
.frame(maxHeight:( (height * factor + 10) * 6) + 20)
.fixedSize()
.background(alignment: .trailing) {
RoundedRectangle(cornerRadius: 20)
.fill(.regularMaterial)
.frame(width: width * factor - 18)
}
.clipShape(.rect(cornerRadii: .init(bottomTrailing: 20, topTrailing: 20)))
.overlay(alignment: .bottomLeading) {
newPenButton
.offset(x: 60, y: 10)
}
}
.frame(maxHeight:( (height * factor + 10) * 7) + 20)
.fixedSize()
.background(alignment: .trailing) {
RoundedRectangle(cornerRadius: 20)
.fill(.regularMaterial)
.frame(width: width * factor - 15)
}
.clipShape(.rect(cornerRadii: .init(bottomTrailing: 20, topTrailing: 20)))
.overlay(alignment: .bottomLeading) {
newPenButton
.offset(x: 60, y: 10)
}
}
@ViewBuilder
@@ -59,7 +103,7 @@ struct PenDockView: View {
Image(tip)
.resizable()
.renderingMode(.template)
.foregroundStyle(Color.rgba(from: pen.color))
.foregroundStyle(Color.rgba(from: pen.rgba))
}
Image(pen.style.icon.base)
.resizable()
@@ -74,12 +118,13 @@ struct PenDockView: View {
tool.selectPen(pen)
}
}
.padding(.leading, 10)
.contextMenu(if: pen.strokeStyle != .eraser) {
ControlGroup {
Button {
let originalPen = pen
let pen = PenObject.createObject(\.viewContext, penStyle: originalPen.style)
pen.color = originalPen.color
pen.color = originalPen.rgba
pen.isSelected = true
pen.tool = tool.object
let _pen = Pen(object: pen)
@@ -109,7 +154,6 @@ struct PenDockView: View {
.contentShape(.dragPreview, .rect(cornerRadius: 10))
}
.onDrop(of: [.item], delegate: PenDropDelegate(id: pen.id, tool: tool))
.padding(.leading, 10)
.offset(x: tool.selectedPen === pen ? 0 : 25)
}
@@ -142,7 +186,7 @@ struct PenDockView: View {
Image(tip)
.resizable()
.renderingMode(.template)
.foregroundStyle(Color.rgba(from: pen.color))
.foregroundStyle(Color.rgba(from: pen.rgba))
}
Image(pen.style.icon.base)
.resizable()

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "transparency_grid 1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "transparency_grid 1@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "transparency_grid 1@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "transparency_grid 1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "transparency_grid 1@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "transparency_grid 1@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB