Cách tạo ra một đường cong mịn - Bezier path curve

1. Tổng quan

  • Hôm trước mình có một project yêu cầu vẽ một đường cong đi qua các điểm mà người dùng đưa tay qua. Yêu cầu là đường cong vẽ ra nhanh, mịn và quản lý được các đường cong đó ví dụ như move cả đường cong đó tới một vị trí khác.
  • Hôm nay mình sẽ hướng dẫn các bạn cách để tạo ra đường cong đó và đảm bảo được các yêu cầu đề ra.
  • Mình sẽ demo trên xCode 8.1 và Objective C, sử dụng bezier path.

2. Hiểu rõ hơn về đường curve

  • Đường cong bezier là gì? Đường cong bezier là một đường cong tham số thường được sử dụng trong đồ hoạ máy tính.
  • Về mặt ứng dụng thì đường cong bezier được sử dụng trong đồ hoạ vector để mô hình hoá đường mong mượt (đường cong mà hôm nay mình sẽ hướng dẫn các bạn tạo ra nó) và những đường cong này có thể được sử dụng phóng to thu nhỏ mà không bị vỡ và theo tỉ lệ không xác định.

2.1. Đường cong bezier

  • Một đường cong bezier được xác định dựa trên một tập các điểm kiểm soát (control point) P0 tới Pn với n được gọi là cấp bậc của đường cong. Với n = 1 thì nó chính là cấp tuyến tính, khi vẽ ra nó là các đường thẳng nối nhau. Với n = 2 thì đường cong được gọi là đường cong bậc 2.
  • Giá trị n càng cao thì bậc càng cao và độ mịn càng cao, tức là số điểm control tăng lên, độ chính xác để xác định đường curve đó càng chính xác, vì vậy mà độ mịn nó sẽ tăng theo.
  • Ở đây trong giới hạn bài này mình sẽ chỉ giới thiệu trong phạm vi đường bậc 1 và bậc 2.

Đường cong tuyến tính (bậc 1 linenear)

  • Với 2 điểm P0 và P1, đường cong Bézier tuyến tính là một đoạn thẳng nối liền với hai điểm đó. Phương trình của đường cong này là:

Đường cong bậc hai(Quadratic)

  • Đường cong Bézier bậc 2 được tạo bởi một hàm B(t), với các điểm P0, P1, và P2 cho trước, khi đó:

2.2. Các điểm control point

Đường cong tuyến tính

  • Trên đây là giả lập về sự thay đổi các giá trị control point. Giá trị t nằm trong khoảng [0, 1]
  • Biến t trong phương trình đường cong bezier tuyến tính có thể được xem như là giá trị khoảng cách của B(t) từ P0 đến P1.
  • Giá trị t từ 0 -> 1 nên B(t) sẽ là một đường thẳng.

Đường cong bậc hai

  • Hình dạng đường cong bậc 2 như sau:

  • Sự biến đổi như sau:

  • Đối với đường cong bezier bậc 2, ta có thể xác định 2 điểm trung gian Q0 và Q1 sao cho t dao động từ 0 đến 1:

    • Điểm Q0 biến đổi từ P0 đến P1 và nó mô tả một đường cong bezier tuyến tính.
    • Điểm Q1 biến đổi từ P1 đến P2 và nó mô tả một đường cong bezier tuyến tính.
    • Điểm B(t) biến đổi từ Q0 đến Q1 và nó mô tả một đường cong bezier bậc 2.

3. Tạo ra bezier path curve

3.1. Custom UIBezierPath

  • Đầu tiên, bạn cần tạo ra subclass cho đường bezier path của bạn. Mình tạm thời đặt tên là NCBezierPath. Tại file .h bạn tạo ra như sau:
#import <UIKit/UIKit.h>

@interface NCBezierPath : UIBezierPath

- (instancetype)initStrokeColor:(UIColor *)color
                      blendMode:(CGBlendMode)mode
                      lineWidth:(CGFloat)width;

@end
  • Bạn cần implement trong file .m để khởi tại và thiết lập mặc định cho đường bezier của bạn
#import "NCBezierPath.h"

@implementation NCBezierPath

- (instancetype)initStrokeColor:(UIColor *)color
                      blendMode:(CGBlendMode)mode
                      lineWidth:(CGFloat)width {
    if (self = [super init]) {
        self.lineCapStyle = kCGLineCapRound;
        self.lineJoinStyle = kCGLineJoinRound;
        self.lineWidth = width;
    }
    return self;
}

@end
  • Mình giải thích một chút về đoạn code trên:
    • Trong file .h mình có khai báo một method init với các tham số truyền vào là path color, path mode, path width. Nó tương ứng với màu đường bạn sẽ vẽ, blend mode và chiều rộng của đường đó. Với thuộc tính CGBlendMode có một vài giá trị cho bạn lựa chọn như sau:
typedef CF_ENUM (int32_t, CGBlendMode) {
    kCGBlendModeNormal,
    kCGBlendModeMultiply,
    kCGBlendModeScreen,
    kCGBlendModeOverlay,
    kCGBlendModeDarken,
    kCGBlendModeLighten,
    kCGBlendModeColorDodge,
    kCGBlendModeColorBurn,
    kCGBlendModeSoftLight,
    kCGBlendModeHardLight,
    kCGBlendModeDifference,
    kCGBlendModeExclusion,
    kCGBlendModeHue,
    kCGBlendModeSaturation,
    kCGBlendModeColor,
    kCGBlendModeLuminosity,
    kCGBlendModeClear,                  /* R = 0 */
    kCGBlendModeCopy,                   /* R = S */
    kCGBlendModeSourceIn,               /* R = S*Da */
    kCGBlendModeSourceOut,              /* R = S*(1 - Da) */
    kCGBlendModeSourceAtop,             /* R = S*Da + D*(1 - Sa) */
    kCGBlendModeDestinationOver,        /* R = S*(1 - Da) + D */
    kCGBlendModeDestinationIn,          /* R = D*Sa */
    kCGBlendModeDestinationOut,         /* R = D*(1 - Sa) */
    kCGBlendModeDestinationAtop,        /* R = S*(1 - Da) + D*Sa */
    kCGBlendModeXOR,                    /* R = S*(1 - Da) + D*(1 - Sa) */
    kCGBlendModePlusDarker,             /* R = MAX(0, (1 - D) + (1 - S)) */
    kCGBlendModePlusLighter             /* R = MIN(1, S + D) */
};
  • Các giá trị blend mode bạn có thể jump vào nó để xem.

3.2. Tạo view để vẽ

  • Bạn cần tạo một subclass của UIView để draw lên nó, ở đây mình tạo với tên DrawingView và khao báo như sau:
#import <UIKit/UIKit.h>

@interface DrawingView : UIView

@property (copy, nonatomic) IBInspectable UIColor *strokeColor;
@property (nonatomic) IBInspectable CGFloat strokeWidth;

/**
 *  Capture a snapshot
 */
- (UIImage *)snapshot;

/**
 *  Clear
 */
- (void)clear;

@end
  • Ở đây mình tạo 2 property là màu của path và chiều rộng của path, có thể custom ngay trên inspector.
  • Hai method tạo ảnh snapshot và xoá path.
  • Bước tiếp theo bạn cần implement để có thể vẽ. 3.2.1. Định nghĩa các giá trị mặc định của path
#define kDefaultStrokeColor [UIColor blackColor]
#define kDefaultStrokeWidth 2.0f

3.2.2 Khai báo biến, path, mode...

UIImage *_bitmap;
CGPoint _points[5];
NSInteger _ctr;
NSInteger _movePointCount;
NCBezierPath *_path;
CGFloat _screenScale;
CGBlendMode _blendMode;
  • Một ảnh bitmap để vẽ nónó
  • Các điểm control point để phục vụ lưu trữ mỗi khi move
  • Biến điểm số điểm control point _ctr
  • Số điểm move
  • Đường bezier path

3.2.3. Implement capture image và clear method

- (UIImage *)snapshot {
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, _screenScale);
    [self.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndPDFContext();
    return image;
}

- (void)clear {
    _path = nil;
    _bitmap = nil;
    [self setNeedsDisplay];
}
  • Nhìn vào 2 method trên có lẽ điều chú ý nhất bạn cần để ý đó là [self setNeedsDisplay]; mục đích của nó là yêu cầu vẽ lại, vậy trong hàm clear kia, khi bạn set _path = nil thì bản thân path nó bị mất hết các point rồi, bạn gọi vẽ lại nó sẽ không còn gì trên view nữa, chính vì vậy mà nó sẽ clear được.

3.2.4. Phần khởi tạo view

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    if (self = [super initWithCoder:aDecoder]) {
        [self prepare];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        [self prepare];
    }
    return self;
}

- (void)prepare {
    _ctr = 0;
    _movePointCount = 0;
    _strokeWidth = kDefaultStrokeWidth;
    _strokeColor = kDefaultStrokeColor;
    _blendMode = kCGBlendModeNormal;
    _screenScale = [UIScreen mainScreen].scale;
    self.multipleTouchEnabled = NO;
}
  • Ở đây mình cung cấp khởi tạo view theo hai cách là initWithCoder, initWithFrame.
  • Thiết lập một số giá trị mặc định cho đường path, bạn cũng có thể thiết lập các giá trị này bên Inspector sau khi kéo view vào storyboard hoặc xib.

3.2.5. Bắt các điểm mà ngón tay move qua trên màn hình. Bắt đầu move

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    _ctr = 0;
    _points[_ctr] = [touch locationInView:self];
    _movePointCount = 0;
    _path = [[NCBezierPath alloc] initStrokeColor:_strokeColor
                                        blendMode:_blendMode
                                        lineWidth:_strokeWidth];
}
  • Giải thích như sau: Khi tay bạn bắt đầu chạm vào màn hình điện thoại bạn reset giá trị _ctr về 0, lấy điểm mà bạn chạm vào trên màn hình rồi lưu nó vào mảng, đồng thời khởi tạo đường BezierPath với kiểu là NCBezierPath

Move

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    _movePointCount++;
    _ctr++;
    _points[_ctr] = [touch locationInView:self];
    if (_ctr == 4) {
        _points[3] = CGPointMake((_points[2].x + _points[4].x) * 0.5, (_points[2].y + _points[4].y) * 0.5);
        [_path moveToPoint:_points[0]];
        [_path addCurveToPoint:_points[3] controlPoint1:_points[1] controlPoint2:_points[2]];
        [self setNeedsDisplay];
        _points[0] = _points[3];
        _points[1] = _points[3]; // this is the "magic"
        _points[2] = _points[4];
        _ctr = 2;
    }
}
  • Giải thích: Với biến _movePointCount mình sẽ giải thích bên dưới mục đích sử dụng của nó. Nhìn vào đoạn code trên, mỗi khi bạn move tay trên màn hình, những điểm mà bắt được sẽ được gọi vào hàm touch move này, vậy mỗi khi bạn bắt được điểm thì bạn add thêm vào mảng, tới khi mảng có số điểm là 4 thì bạn đã đủ số điểm control để tính ra điểm vẽ.
  • Ví dụ như trên bạn cần move tới point đầu tiên, vẽ đường tới point thứ 4, và cần 2 điểm control point (điểm 2 và điểm 3) vì nó là đường bậc 2. Vậy sau khi tính xong, add được đường ta gọi vẽ lại thì điểm đó sẽ được vẽ lên, sau khi vẽ xong bạn lại reset lại để tính lại (lặp lại bước tính). Cứ thế các điểm được vẽ.

Kết thúc move

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    if (_movePointCount < 4) {
        CGFloat startAngle = - ((float)M_PI / 2); // 90 degrees
        CGFloat endAngle = (360 * 2 * (float)M_PI) + startAngle;
        CGFloat radius = _strokeWidth / 2.0;
        [_path addArcWithCenter:_points[0]
                         radius:radius
                     startAngle:startAngle
                       endAngle:endAngle
                      clockwise:0];
    }
    [self drawBitmap];
    [self setNeedsDisplay];
}
  • Ở đây mình sẽ giải thích cho bạn vì sao cần sử dụng biến _movePointCount. Với trường hợp ví dụ bạn touch down và touch up ngay lập tức thì sẽ rơi vào case này, khi đó ở đây mình chỉ xử lý là add một vòng tròn tại điểm đó thôi.

Hành động move bị huỷ

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self touchesCancelled:touches withEvent:event];
}
  • Khi hành động move của bạn bị huỷ thì bạn cần phải kết thúc hành động đó, đơn giản chỉ cần call hàm end.

3.2.6. Draw

- (void)drawBitmap {
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, _screenScale);
    [_strokeColor setStroke];
    [_bitmap drawAtPoint:CGPointZero];
    [_path strokeWithBlendMode:_blendMode alpha:1.0];
    _bitmap = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
}
  • Đoạn code này cũng không có gì đặc biệt, các bạn đọc cũng sẽ hiểu.

3.2.7. Demo

4. Kết thúc

Trên đây mình đã giúp các bạn hiểu về đường cong, cách để tạo ra nó. Thật đơn giản đúng không.