Hackish, partial implementation of arbitrary Path following.

This commit is contained in:
Rob Napier
2019-12-06 22:18:12 -05:00
parent 6f3b909158
commit 1e03ffbe00
4 changed files with 373 additions and 190 deletions

View File

@@ -13,6 +13,8 @@
FB2C366A239AA4AF002DDB93 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB2C3669239AA4AF002DDB93 /* Assets.xcassets */; };
FB2C366D239AA4AF002DDB93 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB2C366C239AA4AF002DDB93 /* Preview Assets.xcassets */; };
FB2C3670239AA4AF002DDB93 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FB2C366E239AA4AF002DDB93 /* LaunchScreen.storyboard */; };
FB2C3678239AFE8B002DDB93 /* Draggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB2C3677239AFE8B002DDB93 /* Draggable.swift */; };
FB2C367A239AFEAD002DDB93 /* PathText.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB2C3679239AFEAD002DDB93 /* PathText.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -24,6 +26,8 @@
FB2C366C239AA4AF002DDB93 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
FB2C366F239AA4AF002DDB93 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
FB2C3671239AA4AF002DDB93 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
FB2C3677239AFE8B002DDB93 /* Draggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draggable.swift; sourceTree = "<group>"; };
FB2C3679239AFEAD002DDB93 /* PathText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathText.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -59,6 +63,8 @@
FB2C3663239AA4AC002DDB93 /* AppDelegate.swift */,
FB2C3665239AA4AC002DDB93 /* SceneDelegate.swift */,
FB2C3667239AA4AC002DDB93 /* ContentView.swift */,
FB2C3677239AFE8B002DDB93 /* Draggable.swift */,
FB2C3679239AFEAD002DDB93 /* PathText.swift */,
FB2C3669239AA4AF002DDB93 /* Assets.xcassets */,
FB2C366E239AA4AF002DDB93 /* LaunchScreen.storyboard */,
FB2C3671239AA4AF002DDB93 /* Info.plist */,
@@ -148,7 +154,9 @@
files = (
FB2C3664239AA4AC002DDB93 /* AppDelegate.swift in Sources */,
FB2C3666239AA4AC002DDB93 /* SceneDelegate.swift in Sources */,
FB2C367A239AFEAD002DDB93 /* PathText.swift in Sources */,
FB2C3668239AA4AC002DDB93 /* ContentView.swift in Sources */,
FB2C3678239AFE8B002DDB93 /* Draggable.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -14,6 +14,7 @@ struct ContentView: View {
@State var P2 = CGPoint(x: 400, y: 700)
@State var P3 = CGPoint(x: 650, y: 500)
let text: NSAttributedString = {
let string = NSString("You can display text along a curve, with bold, color, and big text.")
@@ -27,14 +28,20 @@ struct ContentView: View {
}()
var body: some View {
ZStack{
let path = Path() {
$0.move(to: P0)
$0.addCurve(to: P3, control1: P1, control2: P2)
}
return ZStack{
Path() {
$0.move(to: P0)
$0.addCurve(to: P3, control1: P1, control2: P2)
}
.stroke(Color.blue, lineWidth: 2)
PathText(text: text, P0: P0, P1: P1, P2: P2, P3: P3)
PathText(text: text, path: path)
ControlPoint(position: $P0)
.foregroundColor(.green)
@@ -57,38 +64,6 @@ struct ContentView_Previews: PreviewProvider {
}
}
struct Draggable<Content: View>: View {
let content: Content
@Binding var position: CGPoint
@State private var dragStart: CGPoint? // Drag based on initial touch-point, not center
var body: some View {
content
.position(position)
.gesture(
DragGesture().onChanged {
if self.dragStart == nil {
self.dragStart = self.position
}
if let dragStart = self.dragStart {
self.position = dragStart + $0.translation
}
}
.onEnded { _ in
self.dragStart = nil
}
)
}
}
extension View {
func draggable(position: Binding<CGPoint>) -> some View {
Draggable(content: self, position: position)
}
}
struct ControlPoint: View {
let size = CGSize(width: 13, height: 13)
@@ -102,159 +77,3 @@ struct ControlPoint: View {
.draggable(position: $position)
}
}
struct PathText: UIViewRepresentable {
var text: NSAttributedString
var P0: CGPoint
var P1: CGPoint
var P2: CGPoint
var P3: CGPoint
func makeUIView(context: UIViewRepresentableContext<PathText>) -> PathTextView {
PathTextView()
}
func updateUIView(_ uiView: PathText.UIViewType, context: UIViewRepresentableContext<PathText>) {
uiView.text = text
uiView.P0 = P0
uiView.P1 = P1
uiView.P2 = P2
uiView.P3 = P3
uiView.setNeedsDisplay()
}
}
/*
Draws attributed text along a cubic Bezier path defined by P0, P1, P2, and P3
*/
class PathTextView: UIView {
var P0 = CGPoint.zero
var P1 = CGPoint.zero
var P2 = CGPoint.zero
var P3 = CGPoint.zero
var text: NSAttributedString {
get { textStorage }
set {
textStorage.setAttributedString(newValue)
locations = (0..<layoutManager.numberOfGlyphs).map { [layoutManager] glyphIndex in
layoutManager.location(forGlyphAt: glyphIndex)
}
lineFragmentOrigin = layoutManager
.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil)
.origin
}
}
private let layoutManager = NSLayoutManager()
private let textStorage = NSTextStorage()
private var locations: [CGPoint] = []
private var lineFragmentOrigin = CGPoint.zero
init() {
textStorage.addLayoutManager(layoutManager)
super.init(frame: .zero)
backgroundColor = .clear
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func draw(_ rect: CGRect) {
let context = UIGraphicsGetCurrentContext()!
var offset: CGFloat = 0.0
var lastGlyphPoint = P0
var lastX: CGFloat = 0.0
// Compute location for each glyph, transform the context, and then draw
for (index, location) in locations.enumerated() {
context.saveGState()
let distance = location.x - lastX
offset = getOffset(atDistance: distance, from: lastGlyphPoint, offset: offset)
let glyphPoint = getPoint(forOffset: offset)
let angle = getAngle(forOffset: offset)
lastGlyphPoint = glyphPoint
lastX = location.x
context.translateBy(x: glyphPoint.x, y: glyphPoint.y)
context.rotate(by: angle)
// The "at:" in drawGlyphs is the origin of the line fragment. We've already adjusted the
// context, so take that back out.
let adjustedOrigin = CGPoint(x: -(lineFragmentOrigin.x + location.x),
y: -(lineFragmentOrigin.y + location.y))
layoutManager.drawGlyphs(forGlyphRange: NSRange(location: index, length: 1),
at: adjustedOrigin)
context.restoreGState()
}
}
func getPoint(forOffset t: CGFloat) -> CGPoint {
CGPoint(x: bezier(t, P0.x, P1.x, P2.x, P3.x),
y: bezier(t, P0.y, P1.y, P2.y, P3.y))
}
func getAngle(forOffset t: CGFloat) -> CGFloat {
let dx = bezierPrime(t, P0.x, P1.x, P2.x, P3.x)
let dy = bezierPrime(t, P0.y, P1.y, P2.y, P3.y)
return atan2(dy, dx)
}
// Simplistic routine to find the offset along Bezier that is
// aDistance away from aPoint. anOffset is the offset used to
// generate aPoint, and saves us the trouble of recalculating it
// This routine just walks forward until it finds a point at least
// aDistance away. Good optimizations here would reduce the number
// of guesses, but this is tricky since if we go too far out, the
// curve might loop back on leading to incorrect results. Tuning
// kStep is good start.
func getOffset(atDistance distance: CGFloat, from point: CGPoint, offset: CGFloat) -> CGFloat {
let kStep: CGFloat = 0.001 // 0.0001 - 0.001 work well
var newDistance: CGFloat = 0
var newOffset = offset + kStep
while newDistance <= distance && newOffset < 1.0 {
newOffset += kStep
newDistance = point.distance(to: getPoint(forOffset: newOffset))
}
return newOffset
}
}
// The Bezier function at t
func bezier(_ t: CGFloat, _ P0: CGFloat, _ P1: CGFloat, _ P2: CGFloat, _ P3: CGFloat) -> CGFloat {
(1-t)*(1-t)*(1-t) * P0
+ 3 * (1-t)*(1-t) * t * P1
+ 3 * (1-t) * t*t * P2
+ t*t*t * P3
}
// The slope of the Bezier function at t
func bezierPrime(_ t: CGFloat, _ P0: CGFloat, _ P1: CGFloat, _ P2: CGFloat, _ P3: CGFloat) -> CGFloat {
0
- 3 * (1-t)*(1-t) * P0
+ (3 * (1-t)*(1-t) * P1) - (6 * t * (1-t) * P1)
- (3 * t*t * P2) + (6 * t * (1-t) * P2)
+ 3 * t*t * P3
}
extension CGPoint {
func distance(to other: CGPoint) -> CGFloat {
let dx = x - other.x
let dy = y - other.y
return hypot(dx, dy)
}
static func + (lhs: CGPoint, rhs: CGSize) -> CGPoint {
return CGPoint(x: lhs.x + rhs.width,
y: lhs.y + rhs.height)
}
}

41
CurvyText/Draggable.swift Normal file
View File

@@ -0,0 +1,41 @@
//
// Draggable.swift
// CurvyText
//
// Created by Rob Napier on 12/6/19.
// Copyright © 2019 Rob Napier. All rights reserved.
//
import SwiftUI
struct Draggable<Content: View>: View {
let content: Content
@Binding var position: CGPoint
@State private var dragStart: CGPoint? // Drag based on initial touch-point, not center
var body: some View {
content
.position(position)
.gesture(
DragGesture().onChanged {
if self.dragStart == nil {
self.dragStart = self.position
}
if let dragStart = self.dragStart {
self.position = dragStart + $0.translation
}
}
.onEnded { _ in
self.dragStart = nil
}
)
}
}
extension View {
func draggable(position: Binding<CGPoint>) -> some View {
Draggable(content: self, position: position)
}
}

315
CurvyText/PathText.swift Normal file
View File

@@ -0,0 +1,315 @@
//
// PathText.swift
// CurvyText
//
// Created by Rob Napier on 12/6/19.
// Copyright © 2019 Rob Napier. All rights reserved.
//
import SwiftUI
struct PathText: UIViewRepresentable {
var text: NSAttributedString
var path: Path
func makeUIView(context: UIViewRepresentableContext<PathText>) -> PathTextView {
PathTextView()
}
func updateUIView(_ uiView: PathTextView, context: UIViewRepresentableContext<PathText>) {
uiView.text = text
uiView.path = path
}
}
/*
Draws attributed text along a cubic Bezier path defined by P0, P1, P2, and P3
*/
class PathTextView: UIView {
var path = Path() {
didSet {
setNeedsDisplay()
}
}
var text: NSAttributedString {
get { textStorage }
set {
textStorage.setAttributedString(newValue)
locations = (0..<layoutManager.numberOfGlyphs).map { [layoutManager] glyphIndex in
layoutManager.location(forGlyphAt: glyphIndex)
}
lineFragmentOrigin = layoutManager
.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil)
.origin
}
}
private let layoutManager = NSLayoutManager()
private let textStorage = NSTextStorage()
private let textContainer = NSTextContainer()
private var locations: [CGPoint] = []
private var lineFragmentOrigin = CGPoint.zero
init() {
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
super.init(frame: .zero)
backgroundColor = .clear
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func draw(_ rect: CGRect) {
let tangents = path.tangents(at: locations.map { $0.x })
let sections = path.sections()
guard let pathStart = sections.first?.start else { return }
let context = UIGraphicsGetCurrentContext()!
for (index, (tangent, location)) in zip(tangents, locations).enumerated() {
context.saveGState()
let glyphPoint = tangent.point
let angle = tangent.angle
context.translateBy(x: glyphPoint.x, y: glyphPoint.y)
context.rotate(by: angle)
// The "at:" in drawGlyphs is the origin of the line fragment. We've already adjusted the
// context, so take that back out.
let adjustedOrigin = CGPoint(x: -(lineFragmentOrigin.x + location.x),
y: -(lineFragmentOrigin.y + location.y))
layoutManager.drawGlyphs(forGlyphRange: NSRange(location: index, length: 1),
at: adjustedOrigin)
context.restoreGState()
}
}
}
// The Bezier function at t
func bezier(_ t: CGFloat, _ P0: CGFloat, _ P1: CGFloat, _ P2: CGFloat, _ P3: CGFloat) -> CGFloat {
(1-t)*(1-t)*(1-t) * P0
+ 3 * (1-t)*(1-t) * t * P1
+ 3 * (1-t) * t*t * P2
+ t*t*t * P3
}
// The slope of the Bezier function at t
func bezierPrime(_ t: CGFloat, _ P0: CGFloat, _ P1: CGFloat, _ P2: CGFloat, _ P3: CGFloat) -> CGFloat {
0
- 3 * (1-t)*(1-t) * P0
+ (3 * (1-t)*(1-t) * P1) - (6 * t * (1-t) * P1)
- (3 * t*t * P2) + (6 * t * (1-t) * P2)
+ 3 * t*t * P3
}
extension CGPoint {
func distance(to other: CGPoint) -> CGFloat {
let dx = x - other.x
let dy = y - other.y
return hypot(dx, dy)
}
static func + (lhs: CGPoint, rhs: CGSize) -> CGPoint {
return CGPoint(x: lhs.x + rhs.width,
y: lhs.y + rhs.height)
}
}
struct PathText_Previews: PreviewProvider {
static var previews: some View {
let P0 = CGPoint(x: 50, y: 500)
let P1 = CGPoint(x: 300, y: 300)
let P2 = CGPoint(x: 400, y: 700)
let P3 = CGPoint(x: 650, y: 500)
let path = Path() {
$0.move(to: P0)
$0.addCurve(to: P3, control1: P1, control2: P2)
}
let text: NSAttributedString = {
let string = NSString("You can display text along a curve, with bold, color, and big text.")
let s = NSMutableAttributedString(string: string as String,
attributes: [.font: UIFont.systemFont(ofSize: 16)])
s.addAttributes([.font: UIFont.boldSystemFont(ofSize: 16)], range: string.range(of: "bold"))
s.addAttributes([.foregroundColor: UIColor.red], range: string.range(of: "color"))
s.addAttributes([.font: UIFont.systemFont(ofSize: 32)], range: string.range(of: "big text"))
return s
}()
return PathText(text: text, path: path)
}
}
protocol PathSection {
var start: CGPoint { get }
var end: CGPoint { get }
func tangent(atOffset offset: CGFloat) -> PathTangent
func nextTangent(distance: CGFloat, after: PathTangent) -> NextTangent
}
extension PathSection {
// Default impl
func nextTangent(distance: CGFloat, after lastTangent: PathTangent) -> NextTangent {
// Simplistic routine to find the offset along Bezier that is
// aDistance away from aPoint. anOffset is the offset used to
// generate aPoint, and saves us the trouble of recalculating it
// This routine just walks forward until it finds a point at least
// aDistance away. Good optimizations here would reduce the number
// of guesses, but this is tricky since if we go too far out, the
// curve might loop back on leading to incorrect results. Tuning
// kStep is good start.
// func getOffset(atDistance distance: CGFloat, from point: CGPoint, offset: CGFloat) -> CGFloat {
let point = lastTangent.point
let offset = lastTangent.offset
let kStep: CGFloat = 0.001 // 0.0001 - 0.001 work well
var newDistance: CGFloat = 0
var newOffset = offset + kStep
while newDistance <= distance && newOffset < 1.0 {
newOffset += kStep
newDistance = point.distance(to: tangent(atOffset: newOffset).point) // FIXME: Inefficient
}
if newOffset >= 1.0 {
fatalError() // Implement
// return .insufficient(remaining: <#T##CGFloat#>)
}
return .found(tangent(atOffset: newOffset))
}
}
struct PathTangent {
var offset: CGFloat
var point: CGPoint
var angle: CGFloat
}
enum NextTangent {
case found(PathTangent)
case insufficient(remaining: CGFloat)
}
extension Path {
func sections() -> [PathSection] {
var sections: [PathSection] = []
var start: CGPoint?
var current: CGPoint?
self.forEach { (element) in
// FIXME: Filter zero-length?
switch element {
case .closeSubpath:
sections.append(PathLineSection(start: current ?? .zero, end: start ?? .zero))
current = start
start = nil
case .move(to: let p):
// sections.append(PathMoveSection(to: p))
start = start ?? p
current = p
case let .curve(to: p3, control1: p1, control2: p2):
sections.append(PathCurveSection(p0: current ?? .zero, p1: p1, p2: p2, p3: p3))
start = start ?? .zero
current = p3
case .line(to: let p):
sections.append(PathLineSection(start: current ?? .zero, end: p))
start = start ?? .zero
current = p
case let .quadCurve(to: p2, control: p1):
fatalError()
// sections.append(PathQuadCurveSection(p0: current ?? .zero, p1: p1, p2: p2))
// start = start ?? .zero
// current = p2
}
}
return sections
}
func tangents(at offsets: [CGFloat]) -> [PathTangent] {
var sections = self.sections().reversed()
guard let currentSection = sections.last else { return [] }
var tangents: [PathTangent] = []
var lastTangent = currentSection.tangent(atOffset: 0)
var lastLocation: CGFloat = 0.0
// Compute location for each glyph, transform the context, and then draw
for location in offsets {
let distance = location - lastLocation
switch currentSection.nextTangent(distance: distance, after: lastTangent) {
case .found(let tangent):
tangents.append(tangent)
lastTangent = tangent
lastLocation = location
case .insufficient(remaining: let remaining):
fatalError() // Implement
}
}
return tangents
}
}
struct PathLineSection: PathSection {
let start, end: CGPoint
func tangent(atOffset offset: CGFloat) -> PathTangent {
let dx = end.x - start.x
let dy = end.y - start.y
let x = start.x + dx * offset
let y = start.y + dy * offset
return PathTangent(offset: offset,
point: CGPoint(x: x, y: y),
angle: atan2(dy, dx))
}
}
//struct PathQuadCurveSection: PathSection {
// let p0, p1, p2: CGPoint
// var start: CGPoint { p0 }
// var end: CGPoint { p2 }
//}
struct PathCurveSection: PathSection {
let p0, p1, p2, p3: CGPoint
var start: CGPoint { p0 }
var end: CGPoint { p3 }
func tangent(atOffset offset: CGFloat) -> PathTangent {
let dx = bezierPrime(offset, p0.x, p1.x, p2.x, p3.x)
let dy = bezierPrime(offset, p0.y, p1.y, p2.y, p3.y)
let x = bezier(offset, p0.x, p1.x, p2.x, p3.x)
let y = bezier(offset, p0.y, p1.y, p2.y, p3.y)
return PathTangent(offset: offset,
point: CGPoint(x: x, y: y),
angle: atan2(dy, dx))
}
}