From 84602c48a0f37ccb2dcff63a604d0870377a2d20 Mon Sep 17 00:00:00 2001 From: Yuri Strot Date: Mon, 8 Apr 2019 17:29:41 +0700 Subject: [PATCH] WIP #561: Built-in zoom support --- Example/Example.xcodeproj/project.pbxproj | 10 +- Macaw.xcodeproj/project.pbxproj | 6 + Source/animation/AnimationProducer.swift | 4 +- Source/model/geom2d/Arc.swift | 2 +- Source/model/geom2d/Point.swift | 29 ++-- Source/model/geom2d/Size.swift | 20 ++- Source/model/geom2d/Transform.swift | 9 +- Source/views/MacawView.swift | 89 ++++++++++-- Source/views/MacawZoom.swift | 162 ++++++++++++++++++++++ 9 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 Source/views/MacawZoom.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 5a10c70d..b3207ffd 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -258,8 +258,9 @@ TargetAttributes = { B02E75EC1C16104900D1971D = { CreatedOnToolsVersion = 7.1.1; + DevelopmentTeam = FZXCM5CJ7P; LastSwiftMigration = 1020; - ProvisioningStyle = Manual; + ProvisioningStyle = Automatic; }; }; }; @@ -502,7 +503,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = FZXCM5CJ7P; INFOPLIST_FILE = Example/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -521,13 +523,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = FZXCM5CJ7P; INFOPLIST_FILE = Example/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.exyte.Example.Example; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; name = Release; diff --git a/Macaw.xcodeproj/project.pbxproj b/Macaw.xcodeproj/project.pbxproj index a99ff41e..e9df761b 100644 --- a/Macaw.xcodeproj/project.pbxproj +++ b/Macaw.xcodeproj/project.pbxproj @@ -234,6 +234,8 @@ 5852891620B29D67003E51D1 /* TransformedLocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5852891520B29D67003E51D1 /* TransformedLocus.swift */; }; 5852891720B29D67003E51D1 /* TransformedLocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5852891520B29D67003E51D1 /* TransformedLocus.swift */; }; 5874CCB720DA8A860090DBD5 /* ColorMatrix.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5874CCB620DA8A860090DBD5 /* ColorMatrix.swift */; }; + 5876C63222572859000B31B6 /* MacawZoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5876C63122572859000B31B6 /* MacawZoom.swift */; }; + 5876C63322572859000B31B6 /* MacawZoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5876C63122572859000B31B6 /* MacawZoom.swift */; }; 58944BDA20AC8A9A00657640 /* logo_base64.txt in Resources */ = {isa = PBXBuildFile; fileRef = 57B7A4E01EE70DA5009D78D7 /* logo_base64.txt */; }; 58944BDB20AC8A9A00657640 /* clip.svg in Resources */ = {isa = PBXBuildFile; fileRef = C43B064C1F9738EF00787A35 /* clip.svg */; }; 58B0523920E10E7100D45008 /* ColorMatrix.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5874CCB620DA8A860090DBD5 /* ColorMatrix.swift */; }; @@ -676,6 +678,7 @@ 585288F320AD96A2003E51D1 /* ContentLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentLayout.swift; sourceTree = ""; }; 5852891520B29D67003E51D1 /* TransformedLocus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransformedLocus.swift; sourceTree = ""; }; 5874CCB620DA8A860090DBD5 /* ColorMatrix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorMatrix.swift; sourceTree = ""; }; + 5876C63122572859000B31B6 /* MacawZoom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacawZoom.swift; sourceTree = ""; }; 5B1A8C7520A15F7300E5FFAE /* SVGNodeLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SVGNodeLayout.swift; sourceTree = ""; }; 5B1AE18420B6A669007EECCB /* text-align-01-b-manual.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "text-align-01-b-manual.svg"; sourceTree = ""; }; 5B1AE18520B6A669007EECCB /* paths-data-06-t-manual.reference */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "paths-data-06-t-manual.reference"; sourceTree = ""; }; @@ -1295,6 +1298,7 @@ 57F108731F502A3600DC365B /* Touchable.swift */, 57E5E1501E3B393900D1CB28 /* MacawView.swift */, 57E5E1521E3B393900D1CB28 /* ShapeLayer.swift */, + 5876C63122572859000B31B6 /* MacawZoom.swift */, ); path = views; sourceTree = ""; @@ -2082,6 +2086,7 @@ 57614B3A1F83D15600875933 /* AnimationProducer.swift in Sources */, 585288F620AD96A2003E51D1 /* ContentLayout.swift in Sources */, 57614B3C1F83D15600875933 /* ShapeInterpolation.swift in Sources */, + 5876C63322572859000B31B6 /* MacawZoom.swift in Sources */, 57614B3D1F83D15600875933 /* Graphics_iOS.swift in Sources */, 57614BDB1F8739EE00875933 /* MacawView+PDF.swift in Sources */, 57614B411F83D15600875933 /* Text.swift in Sources */, @@ -2222,6 +2227,7 @@ 57A27BD51E44C5840057BD3A /* ShapeInterpolation.swift in Sources */, A718CD471F45C28700966E06 /* Graphics_iOS.swift in Sources */, 57614BDA1F8739EE00875933 /* MacawView+PDF.swift in Sources */, + 5876C63222572859000B31B6 /* MacawZoom.swift in Sources */, 57E5E1A21E3B393900D1CB28 /* Text.swift in Sources */, 57F1087C1F53CA7E00DC365B /* MDisplayLink_iOS.swift in Sources */, 57E5E1A61E3B393900D1CB28 /* RenderContext.swift in Sources */, diff --git a/Source/animation/AnimationProducer.swift b/Source/animation/AnimationProducer.swift index 244aac25..8ecac9a9 100644 --- a/Source/animation/AnimationProducer.swift +++ b/Source/animation/AnimationProducer.swift @@ -384,8 +384,8 @@ class AnimationContext { func getLayoutTransform(_ renderer: NodeRenderer?) -> Transform { if rootTransform == nil { - if let view = renderer?.view, let node = view.renderer?.node() { - rootTransform = LayoutHelper.calcTransform(node, view.contentLayout, view.bounds.size.toMacaw()) + if let view = renderer?.view { + rootTransform = view.place } } return rootTransform ?? Transform.identity diff --git a/Source/model/geom2d/Arc.swift b/Source/model/geom2d/Arc.swift index 5c728df5..c94035c9 100644 --- a/Source/model/geom2d/Arc.swift +++ b/Source/model/geom2d/Arc.swift @@ -1,4 +1,4 @@ -import Darwin +import Foundation open class Arc: Locus { diff --git a/Source/model/geom2d/Point.swift b/Source/model/geom2d/Point.swift index 97f3125f..cac4f508 100644 --- a/Source/model/geom2d/Point.swift +++ b/Source/model/geom2d/Point.swift @@ -1,3 +1,5 @@ +import Foundation + open class Point: Locus { public let x: Double @@ -11,24 +13,35 @@ open class Point: Locus { } override open func bounds() -> Rect { - return Rect( - x: x, - y: y, - w: 0.0, - h: 0.0) + return Rect(x: x, y: y, w: 0.0, h: 0.0) } open func add(_ point: Point) -> Point { - return Point( - x: self.x + point.x, - y: self.y + point.y) + return Point( x: x + point.x, y: y + point.y) } open func rect(size: Size) -> Rect { return Rect(point: self, size: size) } + open func distance(to point: Point) -> Double { + let dx = point.x - x + let dy = point.y - y + return sqrt(dx * dx + dy * dy) + } + override open func toPath() -> Path { return MoveTo(x: x, y: y).lineTo(x: x, y: y).build() } } + +extension Point: Equatable { + public static func == (lhs: Point, rhs: Point) -> Bool { + return lhs.x == rhs.x + && lhs.y == rhs.y + } + + public static func - (lhs: Point, rhs: Point) -> Size { + return Size(w: lhs.x - rhs.x, h: lhs.y - rhs.y) + } +} diff --git a/Source/model/geom2d/Size.swift b/Source/model/geom2d/Size.swift index 12df24c7..b4a09805 100644 --- a/Source/model/geom2d/Size.swift +++ b/Source/model/geom2d/Size.swift @@ -1,3 +1,5 @@ +import Foundation + open class Size { public let w: Double @@ -13,11 +15,25 @@ open class Size { open func rect(at point: Point = Point.origin) -> Rect { return Rect(point: point, size: self) } + + open func angle() -> Double { + return atan2(h, w) + } + } extension Size { + public static func == (lhs: Size, rhs: Size) -> Bool { - return lhs.w == rhs.w - && lhs.h == rhs.h + return lhs.w == rhs.w && lhs.h == rhs.h } + + public static func + (lhs: Size, rhs: Size) -> Size { + return Size(w: lhs.w + rhs.w, h: lhs.h + rhs.h) + } + + public static func - (lhs: Size, rhs: Size) -> Size { + return Size(w: lhs.w - rhs.w, h: lhs.h - rhs.h) + } + } diff --git a/Source/model/geom2d/Transform.swift b/Source/model/geom2d/Transform.swift index 808822ad..8f731b6c 100644 --- a/Source/model/geom2d/Transform.swift +++ b/Source/model/geom2d/Transform.swift @@ -75,12 +75,19 @@ public final class Transform { return Transform(m11: nm11, m12: nm12, m21: nm21, m22: nm22, dx: ndx, dy: ndy) } + public func apply(to: Point) -> Point { + let x2 = m11 * to.x + m12 * to.x + dx + let y2 = m21 * to.y + m22 * to.y + dy + return Point(x: x2, y: y2) + } + public func invert() -> Transform? { let det = self.m11 * self.m22 - self.m12 * self.m21 if det == 0 { return nil } - return Transform(m11: m22 / det, m12: -m12 / det, m21: -m21 / det, m22: m11 / det, + return Transform(m11: m22 / det, m12: -m12 / det, + m21: -m21 / det, m22: m11 / det, dx: (m21 * dy - m22 * dx) / det, dy: (m12 * dx - m11 * dy) / det) } diff --git a/Source/views/MacawView.swift b/Source/views/MacawView.swift index 53061043..01823fc0 100644 --- a/Source/views/MacawView.swift +++ b/Source/views/MacawView.swift @@ -5,6 +5,7 @@ import UIKit #elseif os(OSX) import AppKit #endif + /// /// MacawView is a main class used to embed Macaw scene into your Cocoa UI. /// You could create your own view extended from MacawView with predefined scene. @@ -43,6 +44,18 @@ open class MacawView: MView, MGestureRecognizerDelegate { } } + public let zoom = MacawZoom() + + public var place: Transform { + return placeManager.placeVar.value + } + + public var placeVar: Variable { + return placeManager.placeVar + } + + private let placeManager = RootPlaceManager() + override open var frame: CGRect { didSet { super.frame = frame @@ -106,6 +119,7 @@ open class MacawView: MView, MGestureRecognizerDelegate { @objc public init?(node: Node, coder aDecoder: NSCoder) { super.init(coder: aDecoder) + zoom.initialize(view: self, onChange: onZoomChange) initializeView() @@ -128,6 +142,7 @@ open class MacawView: MView, MGestureRecognizerDelegate { public override init(frame: CGRect) { super.init(frame: frame) + zoom.initialize(view: self, onChange: onZoomChange) initializeView() } @@ -136,6 +151,11 @@ open class MacawView: MView, MGestureRecognizerDelegate { self.init(node: Group(), coder: aDecoder) } + private func onZoomChange(t: Transform) { + placeManager.setZoom(place: t) + self.setNeedsDisplay() + } + func initializeView() { self.contentLayout = .none self.context = RenderContext(view: self) @@ -191,7 +211,11 @@ open class MacawView: MView, MGestureRecognizerDelegate { return } renderer.calculateZPositionRecursively() - ctx.concatenate(layoutHelper.getTransform(renderer, contentLayout, bounds.size.toMacaw())) + + // TODO: actually we should track all changes + placeManager.setLayout(place: layoutHelper.getTransform(renderer, contentLayout, bounds.size.toMacaw())) + + ctx.concatenate(self.place.toCG()) renderer.render(in: ctx, force: false, opacity: node.opacity) } @@ -210,7 +234,7 @@ open class MacawView: MView, MGestureRecognizerDelegate { defer { ctx.restoreGState() } - let transform = layoutHelper.getTransform(renderer, contentLayout, bounds.size.toMacaw()) + let transform = place.toCG() ctx.concatenate(transform) let loc = location.applying(transform.inverted()) return renderer.findNodeAt(parentNodePath: NodePath(node: Node(), location: loc), ctx: ctx) @@ -225,10 +249,29 @@ open class MacawView: MView, MGestureRecognizerDelegate { return .none } + open override func touchesBegan(_ touches: Set, with event: MEvent?) { + super.touchesBegan(touches, with: event) + zoom.touchesBegan(touches) + } + + open override func touchesMoved(_ touches: Set, with event: MEvent?) { + super.touchesMoved(touches, with: event) + zoom.touchesMoved(touches) + } + + open override func touchesEnded(_ touches: Set, with event: MEvent?) { + super.touchesEnded(touches, with: event) + zoom.touchesEnded(touches) + } + + open override func touchesCancelled(_ touches: Set, with event: MEvent?) { + super.touchesCancelled(touches, with: event) + zoom.touchesEnded(touches) + } + // MARK: - Touches override func mTouchesBegan(_ touches: [MTouchEvent]) { - if !self.node.shouldCheckForPressed() && !self.node.shouldCheckForMoved() && !self.node.shouldCheckForReleased () { @@ -547,9 +590,9 @@ class LayoutHelper { private var prevSize: Size? private var prevRect: Rect? - private var prevTransform: CGAffineTransform? + private var prevTransform: Transform? - public func getTransform(_ nodeRenderer: NodeRenderer, _ layout: ContentLayout, _ size: Size) -> CGAffineTransform { + public func getTransform(_ nodeRenderer: NodeRenderer, _ layout: ContentLayout, _ size: Size) -> Transform { setSize(size: size) let node = nodeRenderer.node() var rect = node?.bounds @@ -566,9 +609,9 @@ class LayoutHelper { if let transform = prevTransform { return transform } - return setTransform(transform: layout.layout(rect: prevRect!, into: size).toCG()) + return setTransform(transform: layout.layout(rect: rect, into: size)) } - return CGAffineTransform.identity + return Transform.identity } public class func calcTransform(_ node: Node, _ layout: ContentLayout, _ size: Size) -> Transform { @@ -624,9 +667,39 @@ class LayoutHelper { prevTransform = nil } - private func setTransform(transform: CGAffineTransform) -> CGAffineTransform { + private func setTransform(transform: Transform) -> Transform { prevTransform = transform return transform } } + +class RootPlaceManager { + + var placeVar = Variable(Transform.identity) + private var places: [Transform] = [.identity, .identity] + + func setLayout(place: Transform) { + if places[1] !== place { + places[1] = place + placeVar.value = recalc() + } + } + + func setZoom(place: Transform) { + if places[0] !== place { + places[0] = place + placeVar.value = recalc() + } + } + + private func recalc() -> Transform { + if places[0] === Transform.identity { + return places[1] + } else if places[1] === Transform.identity { + return places[0] + } + return places[0].concat(with: places[1]) + } + +} diff --git a/Source/views/MacawZoom.swift b/Source/views/MacawZoom.swift new file mode 100644 index 00000000..a420b11e --- /dev/null +++ b/Source/views/MacawZoom.swift @@ -0,0 +1,162 @@ +// +// MacawZoom.swift +// Macaw +// +// Created by Yuri Strot on 4/5/19. +// Copyright © 2019 Exyte. All rights reserved. +// + +import Foundation + +#if os(iOS) +import UIKit +#elseif os(OSX) +import AppKit +#endif + +open class MacawZoom { + + private var view: MView! + private var onChange: ((Transform) -> Void)! + private var touches = [TouchData]() + private var zoomData = ZoomData() + + private var trackMove = false + private var trackScale = false + private var trackRotate = false + + open func enable(move: Bool = true, scale: Bool = true, rotate: Bool = false) { + trackMove = move + trackScale = scale + trackRotate = rotate + if scale || rotate { + view.isMultipleTouchEnabled = true + } + } + + open func disable() { + trackMove = false + trackScale = false + trackRotate = false + } + + open func set(offset: Size? = nil, scale: Double? = nil, angle: Double? = nil) { + let o = offset ?? zoomData.offset + let s = scale ?? zoomData.scale + let a = angle ?? zoomData.angle + zoomData = ZoomData(offset: o, scale: s, angle: a) + onChange(zoomData.transform()) + } + + func initialize(view: MView, onChange: @escaping ((Transform) -> Void)) { + self.view = view + self.onChange = onChange + } + + func touchesBegan(_ touches: Set) { + zoomData = getNewZoom() + self.touches = self.touches.map { TouchData(touch: $0.touch, in: view) } + self.touches.append(contentsOf: touches.map { TouchData(touch: $0, in: view) }) + } + + func touchesMoved(_ touches: Set) { + let zoom = cleanTouches() ?? getNewZoom() + onChange(zoom.transform()) + } + + func touchesEnded(_ touches: Set) { + cleanTouches() + if let touch = touches.first { + if touches.count == 1 && touch.tapCount == 2 && touch.timestamp + 0.3 >= CACurrentMediaTime() { + set(offset: .zero, scale: 1, angle: 0) + } + } + } + + @discardableResult private func cleanTouches() -> ZoomData? { + let newTouches = touches.filter { $0.touch.phase.rawValue < MTouch.Phase.ended.rawValue } + if newTouches.count != touches.count { + zoomData = getNewZoom() + touches = newTouches.map { TouchData(touch: $0.touch, in: view) } + return zoomData + } + return nil + } + + private func getNewZoom() -> ZoomData { + if touches.count == 0 || (touches.count == 1 && !trackMove) { + return zoomData + } + let s1 = touches[0].point + let e1 = touches[0].current(in: view) + if touches.count == 1 { + return zoomData.move(delta: e1 - s1) + } + let s2 = touches[1].point + let e2 = touches[1].current(in: view) + let scale = trackScale ? e1.distance(to: e2) / s1.distance(to: s2) : 1 + let a = trackRotate ? (e1 - e2).angle() - (s1 - s2).angle() : 0 + var offset = Size.zero + if trackMove { + let sina = sin(a) + let cosa = cos(a) + let w = e1.x - scale * (s1.x * cosa - s1.y * sina) + let h = e1.y - scale * (s1.x * sina + s1.y * cosa) + offset = Size(w: w, h: h) + } + return ZoomData(offset: offset, scale: scale, angle: a).combine(with: zoomData) + } + +} + +fileprivate class ZoomData { + + let offset: Size + let scale: Double + let angle: Double + + init(offset: Size = Size.zero, scale: Double = 1, angle: Double = 0) { + self.offset = offset + self.scale = scale + self.angle = angle + } + + func transform() -> Transform { + return Transform.move(dx: offset.w, dy: offset.h).scale(sx: scale, sy: scale).rotate(angle: angle) + } + + func move(delta: Size) -> ZoomData { + return ZoomData(offset: offset + delta, scale: scale, angle: angle) + } + + func combine(with: ZoomData) -> ZoomData { + let sina = sin(angle) + let cosa = cos(angle) + let w = offset.w + scale * (cosa * with.offset.w - sina * with.offset.h) + let h = offset.h + scale * (sina * with.offset.w + cosa * with.offset.h) + let s = scale * with.scale + let a = angle + with.angle + return ZoomData(offset: Size(w: w, h: h), scale: s, angle: a) + } + +} + +fileprivate class TouchData { + + let touch: MTouch + let point: Point + + convenience init(touch: MTouch, in view: MView) { + self.init(touch: touch, point: touch.location(in: view).toMacaw()) + } + + init(touch: MTouch, point: Point) { + self.touch = touch + self.point = point + } + + func current(in view: MView) -> Point { + return touch.location(in: view).toMacaw() + } + +}