《iOS Drawing Practical UIKit Solutions》讀書筆記(四) —— Path Basics
貝瑟爾曲線
貝瑟爾曲線是在繪製路徑時,常用的方式。通過貝瑟爾曲線,我們可以繪製常規的矩形,橢圓,或弧線。同時,通過二次,三次貝瑟爾曲線,我們還可以繪製更加複雜的曲線。現在,我們就一起來看一下,貝爾瑟爾曲線在iOS繪圖中的應用。
UIBezierPath
UIBezierPath是iOS中提供的貝瑟爾曲線類,它提供了簡便的方法呼叫,來建立矩形、橢圓、圓角矩形以及弧線。
Rectangles
+(instancetype)bezierPathWithRect:(CGRect)rect;
Ovals and circles
+ (instancetype)bezierPathWithOvalInRect:(CGRect)rect;
Rounded rectangles
+ (instancetype)bezierPathWithRoundedRect:(CGRect)rect cornerRadius:(CGFloat)cornerRadius; // rounds all corners with the same horizontal and vertical radius
Corner-controlled rounded rectangles
+ (instancetype)bezierPathWithRoundedRect:(CGRect)rect byRoundingCorners:(UIRectCorner)corners cornerRadii:(CGSize)cornerRadii;
Arcs
+ (instancetype)bezierPathWithArcCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle clockwise:(BOOL)clockwise;
基礎的繪製例項
UIBezier曲線有如下特性:
- 曲線可以通過appendPath函式動態增長
- 曲線不必是連續的, 可以分段,最終統一繪製一個貝瑟爾曲線(見下面的例子)
NOTE: 當我們繪製的貝瑟爾曲線太過複雜時,往往會遭遇效能問題。這常見於使用者塗鴉類的應用。對於我們日常的使用,一般是沒有問題的。
#pragma mark - General Geometry
CGPoint RectGetCenter(CGRect rect)
{
return CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect));
}
CGFloat PointDistanceFromPoint(CGPoint p1, CGPoint p2)
{
CGFloat dx = p2.x - p1.x;
CGFloat dy = p2.y - p1.y;
return sqrt(dx*dx + dy*dy);
}
// Radians from degrees
CGFloat RadiansFromDegrees(CGFloat degrees)
{
return degrees * M_PI / 180.0f;
}
CGRect RectAroundCenter(CGPoint center, CGSize size)
{
CGFloat halfWidth = size.width / 2.0f;
CGFloat halfHeight = size.height / 2.0f;
return CGRectMake(center.x - halfWidth, center.y - halfHeight, size.width, size.height);
}
#pragma mark - drawRect
- (void)drawRect:(CGRect)rect {
CGRect fullRect = (CGRect){.size = self.frame.size};
UIBezierPath *bezierPath = [UIBezierPath bezierPath];
// 畫臉
CGRect inset = CGRectInset(fullRect, 32, 32);
UIBezierPath *faceOutline = [UIBezierPath bezierPathWithOvalInRect:inset];
[bezierPath appendPath:faceOutline]; // 將曲線新增到目的曲線
// 繼續畫眼睛,嘴巴
CGRect insetAgain = CGRectInset(inset, 64, 64);
//計算半徑
CGPoint referencePoint = CGPointMake(CGRectGetMinX(insetAgain), CGRectGetMaxY(insetAgain));
CGPoint center = RectGetCenter(inset);
CGFloat radius = PointDistanceFromPoint(center, referencePoint);
// 新增笑臉
UIBezierPath *smile = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:RadiansFromDegrees(40) endAngle:RadiansFromDegrees(140) clockwise:YES];
[bezierPath appendPath:smile];
// 左眼
CGPoint p1 = CGPointMake(CGRectGetMinX(insetAgain), CGRectGetMinX(insetAgain));
CGRect eyeRect1 = RectAroundCenter(p1, CGSizeMake(20, 20));
UIBezierPath *eye1 = [UIBezierPath bezierPathWithRect:eyeRect1];
[bezierPath appendPath:eye1];
// 右眼
CGPoint p2 = CGPointMake(CGRectGetMaxX(insetAgain), CGRectGetMinX(insetAgain));
CGRect eyeRect2 = RectAroundCenter(p2, CGSizeMake(20, 20));
UIBezierPath *eye2 = [UIBezierPath bezierPathWithRect:eyeRect2];
[bezierPath appendPath:eye2];
bezierPath.lineWidth = 3;
[bezierPath stroke];
}
自定義繪圖
當系統自帶的畫圓,矩形等函式不能滿足我們時,我們可以通過如下函式來自定義繪圖
// 指定曲線起始點
- (void)moveToPoint:(CGPoint)point;
// 新增到point的直線
- (void)addLineToPoint:(CGPoint)point;
// 三次貝瑟爾曲線
- (void)addCurveToPoint:(CGPoint)endPoint controlPoint1:(CGPoint)controlPoint1 controlPoint2:(CGPoint)controlPoint2;
// 二次貝瑟爾曲線
- (void)addQuadCurveToPoint:(CGPoint)endPoint controlPoint:(CGPoint)controlPoint;
// 弧線
- (void)addArcWithCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle clockwise:(BOOL)clockwise
例如,繪製五邊形:
UIColor *color = [UIColor greenColor];
[color set];
UIBezierPath *path = [UIBezierPath bezierPath];
path.lineWidth = 4;
path.lineJoinStyle = kCGLineCapRound; // 線終點處理
path.lineCapStyle = kCGLineCapRound; // 線拐角處理
// 設定起始點
[path moveToPoint:CGPointMake(150, 50)];
[path addLineToPoint:CGPointMake(100, 100)];
[path addLineToPoint:CGPointMake(110, 190)];
[path addLineToPoint:CGPointMake(190, 190)];
[path addLineToPoint:CGPointMake(200, 100)];
[path closePath]; // 最後一條線,通過閉合所有的點來實現
[path stroke];
繪製貝瑟爾曲線
一般的,繪製貝瑟爾曲線的目的有兩種:
- 將曲線繪製到Context中,並用fill或stroke方法來填充曲線或僅描邊。當使用stroke方法時,我們應該指定曲線的lineWidth屬性。繪製流程一般會像下面這樣:
myPath.lineWidth = 4.0f;
[[UIColor redColor] setStroke];
[[UIColor greenColor] setFill];
[myPath fill];
[myPath stroke];
- 結合CAShapeLayer,建立異形的view layer
有用的分類
下面是兩個可用於UIBezierPath的分類,可以簡化fill於stroke的設定:
- (void) stroke: (CGFloat) width color: (UIColor *) color
{
CGContextRef context = UIGraphicsGetCurrentContext();
if (context == NULL) COMPLAIN_AND_BAIL(@"No context to draw into", nil);
PushDraw(^{
if (color) [color setStroke];
CGFloat holdWidth = self.lineWidth;
if (width > 0)
self.lineWidth = width;
[self stroke];
self.lineWidth = holdWidth;
});
}
- (void) fill: (UIColor *) fillColor
{
CGContextRef context = UIGraphicsGetCurrentContext();
if (context == NULL) COMPLAIN_AND_BAIL(@"No context to draw into", nil);
PushDraw(^{
if (fillColor)
[fillColor set];
[self fill];
});
}
typedef void (^DrawingBlock)(CGRect bounds);
typedef void (^DrawingStateBlock)();
void PushDraw(DrawingStateBlock block);
void PushLayerDraw(DrawingStateBlock block);
void PushDraw(DrawingStateBlock block)
{
if (!block) return; // nothing to do
CGContextRef context = UIGraphicsGetCurrentContext();
if (context == NULL) COMPLAIN_AND_BAIL(@"No context to draw into", nil);
CGContextSaveGState(context);
block();
CGContextRestoreGState(context);
}
// Improve performance by pre-clipping context
// before beginning layer drawing
void PushLayerDraw(DrawingStateBlock block)
{
if (!block) return; // nothing to do
CGContextRef context = UIGraphicsGetCurrentContext();
if (context == NULL) COMPLAIN_AND_BAIL(@"No context to draw into", nil);
CGContextBeginTransparencyLayer(context, NULL);
block();
CGContextEndTransparencyLayer(context);
}
奇偶填充
當我們用fill填充時,預設曲線內部會被全部填充。
但是,path的屬性:
@property(nonatomic) BOOL usesEvenOddFillRule; // Default is NO. When YES, the even-odd fill rule is used for drawing, clipping, and hit testing.
會將曲線圖像分割成奇偶區域,奇填偶數不填:
Path Bounds and Centers
- 用UIBezierPath的bound屬性,可以獲得包裹所有曲線點的最小矩形
- 用CoreGraphics方法CGPathGetPathBoundingBox可以獲得更精確的矩形
下面是獲取bound center的工具方法:
#pragma mark - Bounds
CGRect PathBoundingBox(UIBezierPath *path)
{
return CGPathGetPathBoundingBox(path.CGPath);
}
CGRect PathBoundingBoxWithLineWidth(UIBezierPath *path)
{
CGRect bounds = PathBoundingBox(path);
return CGRectInset(bounds, -path.lineWidth / 2.0f, -path.lineWidth / 2.0f);
}
CGPoint PathBoundingCenter(UIBezierPath *path)
{
return RectGetCenter(PathBoundingBox(path));
}
CGPoint PathCenter(UIBezierPath *path)
{
return RectGetCenter(path.bounds);
}
Transforming Paths
UIBezierPath 提供了方法applyTransform來讓path調轉:
[path applyTransform:CGAffineTransformMakeRotation(M_PI/9)];
左側是我想要的結果,而右側則是實際的結果:
這是因為我們進行仿射變換的錨點預設在座標的原點,而不是path的center。為了讓變換按照我們自己的想法進行,我們必須將變換以path的center為基準:
void ApplyCenteredPathTransform(UIBezierPath *path, CGAffineTransform transform)
{
CGPoint center = PathBoundingCenter(path);
CGAffineTransform t = CGAffineTransformIdentity;
t = CGAffineTransformTranslate(t, center.x, center.y);
t = CGAffineTransformConcat(transform, t);
t = CGAffineTransformTranslate(t, -center.x, -center.y);
[path applyTransform:t];
}
UIBezierPath *PathByApplyingTransform(UIBezierPath *path, CGAffineTransform transform)
{
UIBezierPath *copy = [path copy];
ApplyCenteredPathTransform(copy, transform);
return copy;
}
void RotatePath(UIBezierPath *path, CGFloat theta)
{
CGAffineTransform t = CGAffineTransformMakeRotation(theta);
ApplyCenteredPathTransform(path, t);
}
void ScalePath(UIBezierPath *path, CGFloat sx, CGFloat sy)
{
CGAffineTransform t = CGAffineTransformMakeScale(sx, sy);
ApplyCenteredPathTransform(path, t);
}
void OffsetPath(UIBezierPath *path, CGSize offset)
{
CGAffineTransform t = CGAffineTransformMakeTranslation(offset.width, offset.height);
ApplyCenteredPathTransform(path, t);
}
void MovePathToPoint(UIBezierPath *path, CGPoint destPoint)
{
CGRect bounds = PathBoundingBox(path);
CGPoint p1 = bounds.origin;
CGPoint p2 = destPoint;
CGSize vector = CGSizeMake(p2.x - p1.x, p2.y - p1.y);
OffsetPath(path, vector);
}
void MovePathCenterToPoint(UIBezierPath *path, CGPoint destPoint)
{
CGRect bounds = PathBoundingBox(path);
CGPoint p1 = bounds.origin;
CGPoint p2 = destPoint;
CGSize vector = CGSizeMake(p2.x - p1.x, p2.y - p1.y);
vector.width -= bounds.size.width / 2.0f;
vector.height -= bounds.size.height / 2.0f;
OffsetPath(path, vector);
}
void MirrorPathHorizontally(UIBezierPath *path)
{
CGAffineTransform t = CGAffineTransformMakeScale(-1, 1);
ApplyCenteredPathTransform(path, t);
}
void MirrorPathVertically(UIBezierPath *path)
{
CGAffineTransform t = CGAffineTransformMakeScale(1, -1);
ApplyCenteredPathTransform(path, t);
}
void FitPathToRect(UIBezierPath *path, CGRect destRect)
{
CGRect bounds = PathBoundingBox(path);
CGRect fitRect = RectByFittingRect(bounds, destRect);
CGFloat scale = AspectScaleFit(bounds.size, destRect);
CGPoint newCenter = RectGetCenter(fitRect);
MovePathCenterToPoint(path, newCenter);
ScalePath(path, scale, scale);
}
void AdjustPathToRect(UIBezierPath *path, CGRect destRect)
{
CGRect bounds = PathBoundingBox(path);
CGFloat scaleX = destRect.size.width / bounds.size.width;
CGFloat scaleY = destRect.size.height / bounds.size.height;
CGPoint newCenter = RectGetCenter(destRect);
MovePathCenterToPoint(path, newCenter);
ScalePath(path, scaleX, scaleY);
}
虛線段
UIBezier曲線設定虛線段很簡單,只需要呼叫函式
- (void)setLineDash:(nullable const CGFloat *)pattern count:(NSInteger)count phase:(CGFloat)phase;
引數: pattern->C型別CGFlot陣列,表明連續的線段點數和空白線段點數。
count->pattern中資料個數
phase-> 起始位置(我們可以通過設定phase,來顯示一個流動的虛線動畫)
例子
CGFloat dashes[] = {6, 2};
[path setLineDash:dashes count:2 phase:0];
繪製多邊形
下面是一個繪製等邊多邊形的功能函式:
UIBezierPath *BezierPolygon(NSUInteger numberOfSides)
{
if (numberOfSides < 3)
{
NSLog(@"Error: Please supply at least 3 sides");
return nil;
}
CGRect destinationRect = CGRectMake(0, 0, 1, 1);
UIBezierPath *path = [UIBezierPath bezierPath];
CGPoint center = RectGetCenter(destinationRect);
CGFloat r = 0.5f; // radius
BOOL firstPoint = YES;
for (int i = 0; i < (numberOfSides - 1); i++)
{
CGFloat theta = M_PI + i * TWO_PI / numberOfSides;
CGFloat dTheta = TWO_PI / numberOfSides;
CGPoint p;
if (firstPoint)
{
p.x = center.x + r * sin(theta);
p.y = center.y + r * cos(theta);
[path moveToPoint:p];
firstPoint = NO;
}
p.x = center.x + r * sin(theta + dTheta);
p.y = center.y + r * cos(theta + dTheta);
[path addLineToPoint:p];
}
[path closePath];
return path;
}
Line Joins and Caps
對於線段的連線點(Join),Quartz給出了3中方式,分別是:
kCGLineJoinMiter
kCGLineJoinRound
kCGLineJoinBevel
對於線段的終點,也給出了三種方式,分別是:
kCGLineCapButt
kCGLineCapSquare
kCGLineCapRound
Miter Limits
Miter limits:
設定最大斜接長度,斜接長度指的是在兩條線交匯處內角和外角之間的距離。超過最大傾斜長度的,連線處將以 lineJoin 為 bevel 來顯示。