问题
Using standard APIs available on iOS 9 and later, how can I achieve a warp effect (something like the following image) when drawing text?
How I would imagine this might work is by specifying essentially four "path segments" which could be either Bézier curves or straight line segments (whatever single "elements" you can normally create within a CGPath
or UIBezierPath
) defining the shape of the four edges of the text's bounding box.
This text doesn't need to be selectable. It may as well be an image, but I'm hoping to find a way to draw it in code, so we don't have to have separate images for each of our localizations. I'd love an answer that uses CoreGraphics, NSString
/NSAttributedString
drawing additions, UIKit/TextKit, or even CoreText. I'd just settle on using images before going as far as OpenGL or Metal, but that doesn't mean I wouldn't still accept a good OpenGL or Metal answer if it is literally the only way to do this.
回答1:
You can achieve this effect using only CoreText and CoreGraphics.
I was able to achieve it using a lot of approximation techniques. Much of what I did using approximation (via CGPathCreateCopyByDashingPath), you could theoretically replace with more clever math. This could both increase performance and make the resultant path smoother.
Basically, you can parameterize the top line and baseline paths (or approximate the parameterization, as I've done). (You can define a function that gets the point at a given percentage along the path.)
CoreText can convert each glyph into a CGPath. Run CGPathApply on each of the glyphs' paths with a function that maps each point along the path to the matching percentage along the line of text. Once you have the point mapped to a horizontal percentage, you can scale it along the line defined by the 2 points at that percentage along your top line and baseline. Scale the point along that line based on the length of the line vs the height of the glyph, and that creates your new point. Save each scaled point to a new CGPath. Fill that path.
I've used CGPathCreateCopyByDashingPath on each glyph as well to create enough points where I don't have to handle the math to curve a long LineTo element (for example). This makes the math more simple, but can leave the path looking a little jagged. To fix this, you could pass the resultant image into a smoothing filter (CoreImage for example), or pass the path to a library that can smooth and simplify the path.
(I did originally just try CoreImage distortion filters to solve the whole problem, but the effects never quite produced the right effect.)
Here is the result (note the slightly jagged edges from using approximation):
Here it is with lines drawn between each percent of the two lines:
Here is how I made it work (180 lines, scrolls):
static CGPoint pointAtPercent(CGFloat percent, NSArray<NSValue *> *pointArray) {
percent = MAX(percent, 0.f);
percent = MIN(percent, 1.f);
int floorIndex = floor(([pointArray count] - 1) * percent);
int ceilIndex = ceil(([pointArray count] - 1) * percent);
CGPoint floorPoint = [pointArray[floorIndex] CGPointValue];
CGPoint ceilPoint = [pointArray[ceilIndex] CGPointValue];
CGPoint midpoint = CGPointMake((floorPoint.x + ceilPoint.x) / 2.f, (floorPoint.y + ceilPoint.y) / 2.f);
return midpoint;
}
static void applierSavePoints(void* info, const CGPathElement* element) {
NSMutableArray *pointArray = (__bridge NSMutableArray*)info;
// Possible to get higher resolution out of this with more point types,
// or by using math to walk the path instead of just saving a bunch of points.
if (element->type == kCGPathElementMoveToPoint) {
[pointArray addObject:[NSValue valueWithCGPoint:element->points[0]]];
}
}
static CGPoint warpPoint(CGPoint origPoint, CGRect pathBounds, CGFloat minPercent, CGFloat maxPercent, NSArray<NSValue*> *baselinePointArray, NSArray<NSValue*> *toplinePointArray) {
CGFloat mappedPercentWidth = (((origPoint.x - pathBounds.origin.x)/pathBounds.size.width) * (maxPercent-minPercent)) + minPercent;
CGPoint baselinePoint = pointAtPercent(mappedPercentWidth, baselinePointArray);
CGPoint toplinePoint = pointAtPercent(mappedPercentWidth, toplinePointArray);
CGFloat mappedPercentHeight = -origPoint.y/(pathBounds.size.height);
CGFloat newX = baselinePoint.x + (mappedPercentHeight * (toplinePoint.x - baselinePoint.x));
CGFloat newY = baselinePoint.y + (mappedPercentHeight * (toplinePoint.y - baselinePoint.y));
return CGPointMake(newX, newY);
}
static void applierWarpPoints(void* info, const CGPathElement* element) {
WPWarpInfo *warpInfo = (__bridge WPWarpInfo*) info;
CGMutablePathRef warpedPath = warpInfo.warpedPath;
CGRect pathBounds = warpInfo.pathBounds;
CGFloat minPercent = warpInfo.minPercent;
CGFloat maxPercent = warpInfo.maxPercent;
NSArray<NSValue*> *baselinePointArray = warpInfo.baselinePointArray;
NSArray<NSValue*> *toplinePointArray = warpInfo.toplinePointArray;
if (element->type == kCGPathElementCloseSubpath) {
CGPathCloseSubpath(warpedPath);
}
// Only allow MoveTo at the beginning. Keep everything else connected to remove the dashing.
else if (element->type == kCGPathElementMoveToPoint && CGPathIsEmpty(warpedPath)) {
CGPoint origPoint = element->points[0];
CGPoint warpedPoint = warpPoint(origPoint, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPathMoveToPoint(warpedPath, NULL, warpedPoint.x, warpedPoint.y);
}
else if (element->type == kCGPathElementAddLineToPoint || element->type == kCGPathElementMoveToPoint) {
CGPoint origPoint = element->points[0];
CGPoint warpedPoint = warpPoint(origPoint, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPathAddLineToPoint(warpedPath, NULL, warpedPoint.x, warpedPoint.y);
}
else if (element->type == kCGPathElementAddQuadCurveToPoint) {
CGPoint origCtrlPoint = element->points[0];
CGPoint warpedCtrlPoint = warpPoint(origCtrlPoint, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPoint origPoint = element->points[1];
CGPoint warpedPoint = warpPoint(origPoint, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPathAddQuadCurveToPoint(warpedPath, NULL, warpedCtrlPoint.x, warpedCtrlPoint.y, warpedPoint.x, warpedPoint.y);
}
else if (element->type == kCGPathElementAddCurveToPoint) {
CGPoint origCtrlPoint1 = element->points[0];
CGPoint warpedCtrlPoint1 = warpPoint(origCtrlPoint1, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPoint origCtrlPoint2 = element->points[1];
CGPoint warpedCtrlPoint2 = warpPoint(origCtrlPoint2, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPoint origPoint = element->points[2];
CGPoint warpedPoint = warpPoint(origPoint, pathBounds, minPercent, maxPercent, baselinePointArray, toplinePointArray);
CGPathAddCurveToPoint(warpedPath, NULL, warpedCtrlPoint1.x, warpedCtrlPoint1.y, warpedCtrlPoint2.x, warpedCtrlPoint2.y, warpedPoint.x, warpedPoint.y);
}
else {
NSLog(@"Error: Unknown Point Type");
}
}
- (NSArray<NSValue *> *)pointArrayFromPath:(CGPathRef)path {
NSMutableArray<NSValue*> *pointArray = [[NSMutableArray alloc] init];
CGFloat lengths[2] = { 1, 0 };
CGPathRef dashedPath = CGPathCreateCopyByDashingPath(path, NULL, 0.f, lengths, 2);
CGPathApply(dashedPath, (__bridge void * _Nullable)(pointArray), applierSavePoints);
CGPathRelease(dashedPath);
return pointArray;
}
- (CGPathRef)createWarpedPathFromPath:(CGPathRef)origPath withBaseline:(NSArray<NSValue *> *)baseline topLine:(NSArray<NSValue *> *)topLine fromPercent:(CGFloat)startPercent toPercent:(CGFloat)endPercent {
CGFloat lengths[2] = { 1, 0 };
CGPathRef dashedPath = CGPathCreateCopyByDashingPath(origPath, NULL, 0.f, lengths, 2);
// WPWarpInfo is just a class I made to hold some stuff.
// I needed it to hold some NSArrays, so a struct wouldn't work.
WPWarpInfo *warpInfo = [[WPWarpInfo alloc] initWithOrigPath:origPath minPercent:startPercent maxPercent:endPercent baselinePointArray:baseline toplinePointArray:topLine];
CGPathApply(dashedPath, (__bridge void * _Nullable)(warpInfo), applierWarpPoints);
CGPathRelease(dashedPath);
return warpInfo.warpedPath;
}
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGMutablePathRef toplinePath = CGPathCreateMutable();
CGPathAddArc(toplinePath, NULL, 187.5, 210.f, 187.5, M_PI, 2 * M_PI, NO);
NSArray<NSValue *> * toplinePoints = [self pointArrayFromPath:toplinePath];
CGContextAddPath(ctx, toplinePath);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokePath(ctx);
CGPathRelease(toplinePath);
CGMutablePathRef baselinePath = CGPathCreateMutable();
CGPathAddArc(baselinePath, NULL, 170.f, 250.f, 50.f, M_PI, 2 * M_PI, NO);
CGPathAddArc(baselinePath, NULL, 270.f, 250.f, 50.f, M_PI, 2 * M_PI, YES);
NSArray<NSValue *> * baselinePoints = [self pointArrayFromPath:baselinePath];
CGContextAddPath(ctx, baselinePath);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokePath(ctx);
CGPathRelease(baselinePath);
// Draw 100 of the connecting lines between the strokes.
/*for (int i = 0; i < 100; i++) {
CGPoint point1 = pointAtPercent(i * 0.01, toplinePoints);
CGPoint point2 = pointAtPercent(i * 0.01, baselinePoints);
CGContextMoveToPoint(ctx, point1.x, point1.y);
CGContextAddLineToPoint(ctx, point2.x, point2.y);
CGContextSetStrokeColorWithColor(ctx, [UIColor blackColor].CGColor);
CGContextStrokePath(ctx);
}*/
NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:@"WARP"];
UIFont *font = [UIFont fontWithName:@"Helvetica" size:144];
[attrString addAttribute:NSFontAttributeName value:font range:NSMakeRange(0, [attrString length])];
CTLineRef line = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)attrString);
CFArrayRef runArray = CTLineGetGlyphRuns(line);
// Just get the first run for this.
CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(runArray, 0);
CTFontRef runFont = CFDictionaryGetValue(CTRunGetAttributes(run), kCTFontAttributeName);
CGFloat fullWidth = (CGFloat)CTRunGetTypographicBounds(run, CFRangeMake(0, CTRunGetGlyphCount(run)), NULL, NULL, NULL);
CGFloat currentOffset = 0.f;
for (int curGlyph = 0; curGlyph < CTRunGetGlyphCount(run); curGlyph++) {
CFRange glyphRange = CFRangeMake(curGlyph, 1);
CGFloat currentGlyphWidth = (CGFloat)CTRunGetTypographicBounds(run, glyphRange, NULL, NULL, NULL);
CGFloat currentGlyphOffsetPercent = currentOffset/fullWidth;
CGFloat currentGlyphPercentWidth = currentGlyphWidth/fullWidth;
currentOffset += currentGlyphWidth;
CGGlyph glyph;
CGPoint position;
CTRunGetGlyphs(run, glyphRange, &glyph);
CTRunGetPositions(run, glyphRange, &position);
CGAffineTransform flipTransform = CGAffineTransformMakeScale(1, -1);
CGPathRef glyphPath = CTFontCreatePathForGlyph(runFont, glyph, &flipTransform);
CGPathRef warpedGylphPath = [self createWarpedPathFromPath:glyphPath withBaseline:baselinePoints topLine:toplinePoints fromPercent:currentGlyphOffsetPercent toPercent:currentGlyphOffsetPercent+currentGlyphPercentWidth];
CGPathRelease(glyphPath);
CGContextAddPath(ctx, warpedGylphPath);
CGContextSetFillColorWithColor(ctx, [UIColor blackColor].CGColor);
CGContextFillPath(ctx);
CGPathRelease(warpedGylphPath);
}
CFRelease(line);
}
The included code is also far from "complete". For example, there are many parts of CoreText I skimmed over. Glyphs with descenders do work, but not well. Some thought would have to go into how to handle those. Also, my letter spacing is sloppy.
Clearly this is a non-trivial problem. I'm sure there are better ways to do this with 3rd party libraries capable of efficiently distorting Bezier paths. However, for the purposes of the intellectual exercise of seeing if it can be done without 3rd party libraries, I think this demonstrates that it can.
Source: https://developer.apple.com/library/mac/samplecode/CoreTextArcCocoa/Introduction/Intro.html
Source: http://www.planetclegg.com/projects/WarpingTextToSplines.html
Source (for making math more clever): Get position of path at time
来源:https://stackoverflow.com/questions/24129650/drawing-warped-text-on-ios