如何使用 Swift/iOS 为人类笔画制作动画?

How to animate a human written stroke using Swift/iOS?

Objective

我正在尝试使用从字形生成的 UIBezierPath 创建人类书写的动画近似值。我理解并阅读了许多 UIBezierPath 问题,这些问题听起来与我的相似(但不相同)。我的目标是创建一个动画,该动画近似于执行字符和字母书写的人类外观。

背景

我创建了一个游乐场,我用它来更好地理解在动画字形中使用的路径,就好像它们是手工绘制的一样。

Apple CAShapeLayer 中的几个概念(strokeStart 和 strokeEnd)在设置动画时似乎确实没有按预期运行。我认为通常人们倾向于认为笔画就像是用书写工具完成的(基本上是直线或曲线)。我们认为笔划和填充一起是一条线,因为我们的书写工具不区分笔划和填充。

但是动画时,路径的轮廓是由线段构成的(填充单独处理,不清楚如何动画填充的位置?)。我想要实现的是一个自然的人写 line/curve,它显示笔划的开始和结束以及动画从开始到结束移动时添加的填充部分。最初这看起来很简单,但我认为它可能需要为填充位置设置动画(不确定如何执行此操作)、笔画 start/end(考虑到上面提到的动画执行方式的意外警告,不确定是否需要这样做),以及利用子路径(如何从已知路径重建)。

方法一

因此,我考虑了路径 (CGPath/UIBezierPath) 的想法。每个路径实际上都包含构建字形所需的所有子路径,因此也许递归这些子路径并使用 CAKeyframeAnimation / CABasicAnimations 和显示部分构建的子路径的动画组将是一个很好的方法(尽管每个子路径的填充位置和描边仍然需要从头到尾制作动画吗?)。

这种方法导致了改进的问题:

如果有完整的UIBezierPath/CGPath,如何访问和创建UIBezierPath/CGPath(子路径)?

如何使用 path/subpath 信息为填充和描边设置动画,就像用书写工具绘制一样? (这似乎意味着需要同时为 CALayer 的 strokeStart/strokeEnd、位置和路径属性设置动画)

注意:

从代码中可以看出,我确实有从字形中获得的完成路径。我可以看到路径描述为我提供了类似路径的信息。如何获取该信息并将其重铸为一组 子路径 人类可写笔画?

(这里的想法是将点信息转换为类人笔画的新数据类型。这意味着需要算法来识别每个填充的起点、坡度、终点和边界)

提示

我在 Pleco(成功实现类似算法的 iOS 应用程序)中注意到,每个笔划都由描述人类可写笔划的闭合路径组成。 UIBezierPath 具有基于连续连接填充的闭合路径。需要一种算法来优化重叠填充,以便为每个 stroke-type 创建不同的闭合路径。

Erica Sadun 在 github 上有一套 path utilities。我还没有完全研究这些文件,但它们可能对确定离散笔画很有用。

UIBezierPath 结构似乎基于连续线的概念 segments/curve。在填充的交点处出现汇合点,代表有方向的路径变化。能否计算出 curve/line 段的 stroke/fill 角度,然后在其他 curves/lines 中搜索相应的汇合点? (即,将一条线段连接到相交填充的间隙以产生两条单独的路径——假设一个人拾取了这些点并用一条新线重新创建了路径 segment/curve)

反思:有没有更简单的方法?我是否缺少关键的 API、一本书或解决此问题的更好方法?

一些替代方法(无用 - 需要加载 gif 或 flash)来产生所需的结果:

Good Example (using Flash) with a presentation layer showing progression of the written stroke. (If possible, this is what I would want to approximate in Swift/iOS) - (alt link - 请参阅左侧的动画图像)

A less good example显示使用渐进路径和填充来近似书写笔画特征(动画不流畅,需要外部资源):

A Flash version - 我熟悉创建 Flash 动画,但我不愿意在 1000 年代实现这些动画(更不用说 iOS 不支持它了,尽管我可能也可以转换一个利用 HTML5 canvas 和 css 动画的算法)。不过这个思路好像有点扯远了,毕竟我要的路径信息是保存在我从fonts/strings提供的glyphs中提取出来的。

方法二

我正在考虑使用 stroke-based font rather than an outline-based 字体来获取正确的路径信息(即填充表示为路径的信息)。如果成功,这种方法将比近似笔画、笔画类型、交叉点和笔画顺序更清晰。我已经向 Apple 提交了雷达,建议将基于笔画的字体添加到 iOS (#20426819)。尽管付出了这些努力,我仍然没有放弃形成一种算法,从贝塞尔曲线路径上找到的线段和曲线中解析出部分笔划、完整笔划、交叉点和汇合点。

更新的想法基于 Discussion/Answers

以下附加信息是根据以下任何正在进行的对话和回答提供的。

笔划顺序很重要,大多数语言(在本例中为中文)都有明确定义stroke types and stroke order rules,似乎提供了一种机制,可以根据每个 CGPathElement 提供的点信息来确定类型和顺序。

CGPathApply 和 CGPathApplierFunction 似乎很有希望作为枚举子路径的方法(保存到数组并应用填充动画)

可以对图层应用蒙版以显示子图层的一部分 (我之前没有使用过这个属性,但看起来如果我可以在子路径上移动一个遮罩层可能有助于动画填充?)

每条路径都定义了大量的点。就好像 BezierPath 是仅使用字形的轮廓定义的。这一事实使得理解交叉填充的开始、结束和并集成为消除特定填充歧义的重要因素。

其他 external libraries are available that may allow one to better resolve stroke behavior. Other technology like the Saffron Type System 或其衍生物之一可能适用于此问题域。

仅对笔画进行动画处理的最简单解决方案的一个基本问题是,可用的 iOS 字体是轮廓字体,而不是基于笔画的字体。一些商业制造商确实生产基于笔画的字体。如果您有其中之一用于测试,请随时使用 link to the playground file

我认为这是一个常见问题,我将继续更新 post 以寻求解决方案。如果需要更多信息或者我可能遗漏了一些必要的概念,请在评论中告诉我。

可能的解决方案

我一直在寻找最简单的解决方案。问题源于字体的结构是轮廓字体而不是基于笔画的。我找到了一个基于笔划的字体样本进行测试,并用它来评估 概念验证 (see video)。我现在正在寻找一种扩展的单笔画字体(包括汉字)来进一步评估。一个不太简单的解决方案可能是找到一种方法来创建一个跟随填充的笔画,然后使用简单的二维几何来评估首先为哪个笔画设置动画(例如,中国规则在笔画顺序上非常明确)。

Link to Playground on Github

游乐场代码

import CoreText
import Foundation
import UIKit
import QuartzCore
import XCPlayground

//research layers
//var l:CALayer? = nil
//var txt:CATextLayer? = nil
//var r:CAReplicatorLayer? = nil
//var tile:CATiledLayer? = nil
//var trans:CATransformLayer? = nil
//var b:CAAnimation?=nil

//  Setup playground to run in full simulator (⌘-0:Select Playground File; ⌘-alt-0:Choose option Run in Full Simulator)

//approach 2 using a custom stroke font requires special font without an outline whose path is the actual fill
var customFontPath = NSBundle.mainBundle().pathForResource("cwTeXFangSong-zhonly", ofType: "ttf")

//  Within the playground folder create Resources folder to hold fonts. NB - Sources folder can also be created to hold additional Swift files
//ORTE1LOT.otf
//NISC18030.ttf
//cwTeXFangSong-zhonly
//cwTeXHei-zhonly
//cwTeXKai-zhonly
//cwTeXMing-zhonly
//cwTeXYen-zhonly

var customFontData = NSData(contentsOfFile: customFontPath!) as! CFDataRef
var error:UnsafeMutablePointer<Unmanaged<CFError>?> = nil
var provider:CGDataProviderRef = CGDataProviderCreateWithCFData ( customFontData )
var customFont = CGFontCreateWithDataProvider(provider) as CGFont!

let registered =  CTFontManagerRegisterGraphicsFont(customFont, error)

if !registered  {
    println("Failed to load custom font: ")

}

let string:NSString = "五"
//"ABCDEFGHIJKLMNOPQRSTUVWXYZ一二三四五六七八九十什我是美国人"

//use the Postscript name of the font
let font =  CTFontCreateWithName("cwTeXFangSong", 72, nil)
//HiraMinProN-W6
//WeibeiTC-Bold
//OrachTechDemo1Lotf
//XinGothic-Pleco-W4
//GB18030 Bitmap

var count = string.length

//must initialize with buffer to enable assignment within CTFontGetGlyphsForCharacters
var glyphs = Array<CGGlyph>(count: string.length, repeatedValue: 0)
var chars = [UniChar]()

for index in 0..<string.length {

    chars.append(string.characterAtIndex(index))

}

//println ("\(chars)") //ok

//println(font)
//println(chars)
//println(chars.count)
//println(glyphs.count)

let gotGlyphs = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, chars.count)

//println(glyphs)
//println(glyphs.count)

if gotGlyphs {

    // loop and pass paths to animation function
    let cgpath = CTFontCreatePathForGlyph(font, glyphs[0], nil)

    //how to break the path apart?
    let path = UIBezierPath(CGPath: cgpath)
    //path.hashValue
    //println(path)

    //  all shapes are closed paths
    //  how to distinguish overlapping shapes, confluence points connected by line segments?
    //  compare curve angles to identify stroke type
    //  for curves that intersect find confluence points and create separate line segments by adding the linesegmens between the gap areas of the intersection

    /* analysis of movepoint

        This method implicitly ends the current subpath (if any) and 
        sets the current point to the value in the point parameter. 
        When ending the previous subpath, this method does not actually 
        close the subpath. Therefore, the first and last points of the 
        previous subpath are not connected to each other.

        For many path operations, you must call this method before 
        issuing any commands that cause a line or curve segment to be 
        drawn.
*/


    //CGPathApplierFunction should allow one to add behavior to each glyph obtained from a string (Swift version??)

//    func processPathElement(info:Void,  element: CGPathElement?) {
//        var pointsForPathElement=[UnsafeMutablePointer<CGPoint>]()
//        if let e = element?.points{
//            pointsForPathElement.append(e)
//            
//        }
//    }
//    
//    var pathArray = [CGPathElement]() as! CFMutableArrayRef

    //var pathArray = Array<CGPathElement>(count: 4, repeatedValue: 0)

    //CGPathApply(<#path: CGPath!#>, <#info: UnsafeMutablePointer<Void>#>, function: CGPathApplierFunction)


//    CGPathApply(path.CGPath, info: &pathArray, function:processPathElement)

    /*
    NSMutableArray *pathElements = [NSMutableArray arrayWithCapacity:1];
    // This contains an array of paths, drawn to this current view
    CFMutableArrayRef existingPaths = displayingView.pathArray;
    CFIndex pathCount = CFArrayGetCount(existingPaths);
    for( int i=0; i < pathCount; i++ ) {
    CGMutablePathRef pRef = (CGMutablePathRef) CFArrayGetValueAtIndex(existingPaths, i);
    CGPathApply(pRef, pathElements, processPathElement);
    }
    */

    //given the structure
    let pathString = path.description
//    println(pathString)
    //regex patthern matcher to produce subpaths?
    //...
    //must be simpler method
    //...

    /* 
        NOTES:
        Use assistant editor to view 
        UIBezierPath String

        http://www.google.com/fonts/earlyaccess
        Stroke-based fonts
        Donald Knuth

    */
//    var redColor = UIColor.redColor()
//    redColor.setStroke()


    var pathLayer = CAShapeLayer()
    pathLayer.frame = CGRect(origin: CGPointZero, size: CGSizeMake(300.0,300.0))
    pathLayer.lineJoin = kCALineJoinRound
    pathLayer.lineCap = kCALineCapRound
    //pathLayer.backgroundColor = UIColor.whiteColor().CGColor
    pathLayer.strokeColor = UIColor.redColor().CGColor
    pathLayer.path = path.CGPath


    //    pathLayer.backgroundColor = UIColor.redColor().CGColor

    // regarding strokeStart, strokeEnd
    /* These values define the subregion of the path used to draw the
    * stroked outline. The values must be in the range [0,1] with zero
    * representing the start of the path and one the end. Values in
    * between zero and one are interpolated linearly along the path
    * length. strokeStart defaults to zero and strokeEnd to one. Both are
    * animatable. */
    var pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
    pathAnimation.duration = 10.0
    pathAnimation.fromValue = NSNumber(float: 0.0)
    pathAnimation.toValue = NSNumber(float: 1.0)

    /*
    var fillAnimation = CABasicAnimation (keyPath: "fill")
    fillAnimation.fromValue = UIColor.blackColor().CGColor
    fillAnimation.toValue = UIColor.blueColor().CGColor
    fillAnimation.duration = 10.0
   pathLayer.addAnimation(fillAnimation, forKey: "fillAnimation") */

    //given actual behavior of boundary animation, it is more likely that some other animation will better simulate a written stroke

    var someView = UIView(frame: CGRect(origin: CGPointZero, size: CGSizeMake(300.0, 300.0)))
    someView.layer.addSublayer(pathLayer)


    //SHOW VIEW IN CONSOLE (ASSISTANT EDITOR)
     XCPShowView("b4Animation", someView)

    pathLayer.addAnimation(pathAnimation, forKey: "strokeEndAnimation")

    someView.layer.addSublayer(pathLayer)

    XCPShowView("Animation", someView)




}

您想查看 CGPathApply(这是对您的更精细问题的简短回答)。您为它提供一个函数,它将为路径的每个元素(这些将是线、弧和闭合)调用该函数。您可以使用它来重建每个已关闭的项目,并将它们存储到列表中。然后你可以找出每个项目的绘制方向(我认为这实际上可能是最难的部分)而不是使用 strokeStart/strokeEnd 每个子路径将它绘制在一个带有遮罩的图层中并将遮罩移动到图层上.

A couple of concepts in the Apple CAShapeLayer (strokeStart and strokeEnd) really don't seem to operate as expected when animated.

但是 strokeEnd 的动画肯定正是您想要做的。在另一个之上使用多个 CAShapeLayers,每个代表一笔笔画以形成所需的字符形状。

进度报告

此答案 post 旨在强调在解决此问题方面取得的重大进展。在问题中添加了如此多的细节后,我只想澄清解决方案的进展情况,并最终(当实现时)明确答案。尽管我 select 的回答很有帮助,但请考虑此 post 以获得完整的解决方案。

方法 # 1

使用现有的 UIBezierPath 信息来识别路径段(并最终)利用这些段(及其坐标)来描边每个子路径(根据可用的语言规则)。

(当前想法)

Erica Sadun 正在生产 SwiftSlowly repo on Github that supplies many functions on paths, including what appears to be a promising library on Segments (of a UIBezierPath), Line Intersections and many functions to act on these items. I have not had the time to review completely but I can envision that one might deconstruct a given path into segments based on the known stroke types. Once all stroke types are known for a given path, one might then evaluate the relative path coordinates to assign stroke-order。之后根据笔画顺序简单地为笔画(UIBezierPath 的子类)设置动画。

方法 # 2

使用基于描边的字体而不是基于轮廓的字体。

(当前想法)

我找到了一个基于笔画的字体示例,并且能够为笔画设置动画。这些字体带有内置笔顺。我无法访问也支持中文的完整笔画字体,但鼓励任何了解此类字体的人在评论中回复。

我已向 Apple 推荐他们在未来的版本中提供基于笔划的字体。 Swift 游乐场笔记和文件(带有示例笔画字体)包含在上面的问题中。如果您有建设性的东西要添加到此解决方案,请发表评论或 post 一个答案。

笔顺规则

参见明文website中描述的笔顺规则。