mirror of
https://github.com/dscyrescotti/Memola.git
synced 2026-07-10 23:02:55 +02:00
feat: implement canvas
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
//
|
||||
// CanvasViewController.swift
|
||||
// Memola
|
||||
//
|
||||
// Created by Dscyre Scotti on 5/4/24.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import MetalKit
|
||||
import Foundation
|
||||
|
||||
class CanvasViewController: UIViewController {
|
||||
let drawingView: DrawingView
|
||||
let scrollView: UIScrollView = UIScrollView()
|
||||
var renderView: MTKView {
|
||||
drawingView.renderView
|
||||
}
|
||||
|
||||
let tool: Tool
|
||||
let canvas: Canvas
|
||||
let history: History
|
||||
let renderer: Renderer
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(tool: Tool, canvas: Canvas, history: History) {
|
||||
self.tool = tool
|
||||
self.canvas = canvas
|
||||
self.history = history
|
||||
self.drawingView = DrawingView(tool: tool, canvas: canvas, history: history)
|
||||
self.renderer = Renderer(canvasView: drawingView.renderView)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
configureViews()
|
||||
configureGestures()
|
||||
configureListeners()
|
||||
|
||||
loadBoard()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
resizeDocumentView()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
drawingView.disableUserInteraction()
|
||||
drawingView.updateDrawableSize(with: view.frame.size)
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
drawingView.enableUserInteraction()
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func configureViews() {
|
||||
view.backgroundColor = .white
|
||||
renderView.autoResizeDrawable = false
|
||||
renderView.enableSetNeedsDisplay = true
|
||||
renderView.translatesAutoresizingMaskIntoConstraints = false
|
||||
renderView.clearColor = MTLClearColor(red: 1, green: 1, blue: 1, alpha: 1)
|
||||
view.addSubview(renderView)
|
||||
NSLayoutConstraint.activate([
|
||||
renderView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
renderView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
renderView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
renderView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
|
||||
scrollView.maximumZoomScale = canvas.maximumZoomScale
|
||||
scrollView.minimumZoomScale = canvas.minimumZoomScale
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
scrollView.isScrollEnabled = true
|
||||
scrollView.showsVerticalScrollIndicator = true
|
||||
scrollView.showsHorizontalScrollIndicator = true
|
||||
scrollView.delegate = self
|
||||
scrollView.backgroundColor = .clear
|
||||
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
|
||||
scrollView.addSubview(drawingView)
|
||||
drawingView.backgroundColor = .clear
|
||||
drawingView.isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
func resizeDocumentView(to newSize: CGSize? = nil) {
|
||||
scrollView.layoutIfNeeded()
|
||||
|
||||
let size = canvas.size
|
||||
let widthScale = (newSize?.width ?? view.frame.width) / size.width
|
||||
let heightScale = (newSize?.height ?? view.frame.height) / size.height
|
||||
let scale = max(widthScale, heightScale)
|
||||
|
||||
let width = size.width * scale
|
||||
let height = size.height * scale
|
||||
let newFrame = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
drawingView.frame = newFrame
|
||||
|
||||
scrollView.setZoomScale(canvas.minimumZoomScale, animated: true)
|
||||
centerDocumentView(to: newSize)
|
||||
|
||||
let offsetX = (newFrame.width * canvas.minimumZoomScale - view.frame.width) / 2
|
||||
let offsetY = (newFrame.height * canvas.minimumZoomScale - view.frame.height) / 2
|
||||
|
||||
let point = CGPoint(x: offsetX, y: offsetY)
|
||||
scrollView.setContentOffset(point, animated: true)
|
||||
|
||||
drawingView.updateDrawableSize(with: view.frame.size)
|
||||
}
|
||||
|
||||
func centerDocumentView(to newSize: CGSize? = nil) {
|
||||
let documentViewSize = drawingView.frame.size
|
||||
let scrollViewSize = newSize ?? view.frame.size
|
||||
let verticalPadding = documentViewSize.height < scrollViewSize.height ? (scrollViewSize.height - documentViewSize.height) / 2 : 0
|
||||
let horizontalPadding = documentViewSize.width < scrollViewSize.width ? (scrollViewSize.width - documentViewSize.width) / 2 : 0
|
||||
self.scrollView.contentInset = UIEdgeInsets(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func configureListeners() {
|
||||
canvas.$state
|
||||
.sink { [weak self] state in
|
||||
self?.canvasStateChanged(state)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
tool.$selectedPen
|
||||
.sink { [weak self] pen in
|
||||
self?.penChanged(to: pen)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
history.historyPublisher
|
||||
.sink { [weak self] action in
|
||||
switch action {
|
||||
case .undo:
|
||||
self?.historyUndid()
|
||||
case .redo:
|
||||
self?.historyRedid()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func loadBoard() {
|
||||
canvas.load()
|
||||
}
|
||||
|
||||
func canvasStateChanged(_ state: Canvas.State) {
|
||||
guard state == .loaded else { return }
|
||||
renderView.delegate = self
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController: MTKViewDelegate {
|
||||
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { }
|
||||
|
||||
func draw(in view: MTKView) {
|
||||
guard view.drawableSize != .zero else {
|
||||
return
|
||||
}
|
||||
canvas.updateTransform(on: drawingView)
|
||||
renderer.draw(in: view, on: canvas)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func configureGestures() {
|
||||
let drawingPanGesture = UIPanGestureRecognizer(target: self, action: #selector(recognizePanGesture))
|
||||
drawingPanGesture.maximumNumberOfTouches = 1
|
||||
drawingPanGesture.minimumNumberOfTouches = 1
|
||||
drawingView.addGestureRecognizer(drawingPanGesture)
|
||||
|
||||
let drawingTapGesture = UITapGestureRecognizer(target: self, action: #selector(recognizeTapGesture))
|
||||
drawingTapGesture.numberOfTapsRequired = 1
|
||||
drawingView.addGestureRecognizer(drawingTapGesture)
|
||||
}
|
||||
|
||||
@objc func recognizePanGesture(_ gesture: UIPanGestureRecognizer) {
|
||||
let point = gesture.location(in: drawingView)
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
drawingView.touchBegan(on: point)
|
||||
case .changed:
|
||||
drawingView.touchMoved(to: point)
|
||||
case .ended:
|
||||
drawingView.touchEnded(to: point)
|
||||
case .cancelled:
|
||||
drawingView.touchEnded(to: point)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc func recognizeTapGesture(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: drawingView)
|
||||
drawingView.touchBegan(on: point)
|
||||
drawingView.touchEnded(to: point)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController: UIScrollViewDelegate {
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
drawingView
|
||||
}
|
||||
|
||||
func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
|
||||
magnificationStarted()
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
canvas.setZoomScale(scrollView.zoomScale)
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
}
|
||||
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
centerDocumentView()
|
||||
magnificationEnded()
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
draggingStarted()
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
if !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating {
|
||||
scrollViewDidEndScrolling(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if !decelerate, scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating {
|
||||
scrollViewDidEndScrolling(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrolling(_ scrollView: UIScrollView) {
|
||||
draggingEnded()
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func magnificationStarted() {
|
||||
guard !renderer.updatesViewPort else { return }
|
||||
canvas.updateClipBounds(scrollView, on: drawingView)
|
||||
drawingView.disableUserInteraction()
|
||||
renderer.updatesViewPort = true
|
||||
}
|
||||
|
||||
func magnificationEnded() {
|
||||
renderer.updatesViewPort = false
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
drawingView.enableUserInteraction()
|
||||
}
|
||||
|
||||
func draggingStarted() {
|
||||
guard !renderer.updatesViewPort else { return }
|
||||
canvas.updateClipBounds(scrollView, on: drawingView)
|
||||
drawingView.disableUserInteraction()
|
||||
renderer.updatesViewPort = true
|
||||
}
|
||||
|
||||
func draggingEnded() {
|
||||
renderer.updatesViewPort = false
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
drawingView.enableUserInteraction()
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func penChanged(to pen: Pen?) {
|
||||
if let pen, let device = drawingView.renderView.device {
|
||||
pen.style.loadTexture(on: device)
|
||||
}
|
||||
let isPenSelected = pen != nil
|
||||
scrollView.isScrollEnabled = !isPenSelected
|
||||
drawingView.isUserInteractionEnabled = isPenSelected
|
||||
isPenSelected ? drawingView.enableUserInteraction() : drawingView.disableUserInteraction()
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasViewController {
|
||||
func historyUndid() {
|
||||
guard history.undo() else { return }
|
||||
drawingView.disableUserInteraction()
|
||||
canvas.graphicContext.undoGraphic()
|
||||
renderer.redrawsGraphicRender = true
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
drawingView.enableUserInteraction()
|
||||
}
|
||||
|
||||
func historyRedid() {
|
||||
guard let event = history.redo() else { return }
|
||||
drawingView.disableUserInteraction()
|
||||
canvas.graphicContext.redoGraphic(for: event)
|
||||
renderer.redrawsGraphicRender = true
|
||||
renderer.resize(on: renderView, to: renderView.drawableSize)
|
||||
renderView.draw()
|
||||
drawingView.enableUserInteraction()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// DrawingView.swift
|
||||
// Memola
|
||||
//
|
||||
// Created by Dscyre Scotti on 5/4/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MetalKit
|
||||
import Foundation
|
||||
|
||||
class DrawingView: UIView {
|
||||
let tool: Tool
|
||||
let canvas: Canvas
|
||||
let history: History
|
||||
let renderView: MTKView
|
||||
|
||||
var ratio: CGFloat { canvas.size.width / bounds.width }
|
||||
|
||||
private var disablesUserInteraction: Bool = false
|
||||
|
||||
required init(tool: Tool, canvas: Canvas, history: History) {
|
||||
self.tool = tool
|
||||
self.canvas = canvas
|
||||
self.history = history
|
||||
self.renderView = MTKView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func updateDrawableSize(with size: CGSize) {
|
||||
renderView.drawableSize = size.multiply(by: 2.5)
|
||||
}
|
||||
|
||||
func touchBegan(on point: CGPoint) {
|
||||
guard !disablesUserInteraction else { return }
|
||||
guard let pen = tool.selectedPen else { return }
|
||||
let stroke = canvas.beginTouch(at: point.muliply(by: ratio), pen: pen)
|
||||
renderView.draw()
|
||||
history.addUndo(.stroke(stroke))
|
||||
history.resetRedo()
|
||||
}
|
||||
|
||||
func touchMoved(to point: CGPoint) {
|
||||
guard !disablesUserInteraction else { return }
|
||||
canvas.moveTouch(to: point.muliply(by: ratio))
|
||||
renderView.draw()
|
||||
}
|
||||
|
||||
func touchEnded(to point: CGPoint) {
|
||||
guard !disablesUserInteraction else { return }
|
||||
canvas.endTouch(at: point.muliply(by: ratio))
|
||||
renderView.draw()
|
||||
}
|
||||
|
||||
func disableUserInteraction() {
|
||||
disablesUserInteraction = true
|
||||
}
|
||||
|
||||
func enableUserInteraction() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||
self?.disablesUserInteraction = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// CanvasView.swift
|
||||
// Memola
|
||||
//
|
||||
// Created by Dscyre Scotti on 5/4/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CanvasView: UIViewControllerRepresentable {
|
||||
@EnvironmentObject var tool: Tool
|
||||
@EnvironmentObject var canvas: Canvas
|
||||
@EnvironmentObject var history: History
|
||||
|
||||
func makeUIViewController(context: Context) -> CanvasViewController {
|
||||
CanvasViewController(tool: tool, canvas: canvas, history: history)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: CanvasViewController, context: Context) { }
|
||||
}
|
||||
Reference in New Issue
Block a user