CALayerでお絵描きを処理してみる

UIViewにお絵描きをする方法は載っているんですが、CALayerでのお絵描き方法は探した中では見つからず手探りでがんばってみました!

参考
[iOSアプリ開発] タッチでお絵かきしてみる | Developers.IO

まずCALayerでお絵描きをする場合とUIViewでお絵描きをする場合の違いはUIViewだとどのviewに描いてるのか明確に出来ます。
しかしLayerだとそれが大変になります。

    CALayer *layer = [CALayer layer];
    layer.frame = self.canvas.bounds;
    layer.name = @"layer";
    
    [self.canvas.layer addSublayer:layer];

導入部分です。色んなサイト見て回るとsetValueで追加する方法も有るんですが、追加は出来ますが書き換えが上手くいかずに頓挫してしまいました。
で、nameで名付けしてやる方法を採用しました。後で述べますがこの方法も厄介なんですよね。

まず線を描くのにUIBezierPathを使って描画しています。

touchメソッドの基本的な処理は

    // タッチした座標を取得します。
    CGPoint currentPoint = [[touches anyObject] locationInView:self.canvas];
    // パスにポイントを追加します。
    [self.bezierPath addLineToPoint:currentPoint];
    // 線を描画します。
    [self drawLine:self.bezierPath];

という流れに終始しておりdrawLineメソッドで実際の描画をしております。
では問題のdrawLineメソッドです。

- (void)drawLine:(UIBezierPath*)path
{
    // 非表示の描画領域を生成します。
    UIGraphicsBeginImageContext(self.canvas.frame.size);
    CALayer *layer = nil;
    CALayer *child = nil;
    int hit = 0;
    int count = 0;
    for(layer in self.canvas.layer.sublayers){
        if([layer.name isEqualToString:@"layer"]){
            child = layer;
            hit = count;
        }
        ++count;
    }
    CALayer *new = [CALayer layer];
    new.frame = self.canvas.frame;
    new.name = child.name;
    // 描画領域に、前回までに描画した画像を、描画します。
    UIImage *old = [[UIImage alloc]initWithCGImage:(CGImageRef)child.contents];
    [old drawAtPoint:CGPointZero];
    
    // 色をセットします。
    [[UIColor blackColor] setStroke];
    
    // 線を引きます。
    [path stroke];
    
    // 描画した画像をcanvasにセットして、画面に表示します。
    new.contents = (id)[UIGraphicsGetImageFromCurrentImageContext() CGImage];
    
    [self.canvas.layer replaceSublayer:[self.canvas.layer.sublayers objectAtIndex:hit] with:new];
    // 描画を終了します。
    UIGraphicsEndImageContext();
}

まず描画領域を確保し、次にCALayerのインスタンスの作成。
childは実際にhitしたCALayerを受け取る変数です。受け取るのは今まで描画したデータとなります。
childを元にUIImageを作成しそれを描画領域にコピーします。
[old drawAtPoint:CGPointZero];がその部分です。

で、新しく描いた軌跡を [path stroke]で描きそれを、
new.contents = (id)[UIGraphicsGetImageFromCurrentImageContext() CGImage];
とUIImage化UIImageRef化と一気にしてnewのcontentsにぶち込んでいます。

次、これが一番のポイントです。
CALayerの何が面倒かというとsublayersがNSArrayなんですよね。そして追加は用意されていますが削除は一気に消すか、
layerにnilを入れるかしかないんです。で、見てたら

/* Remove 'layer' from the sublayers array of the receiver and insert
 * 'layer2' if non-nil in its position. If the superlayer of 'layer'
 * is not the receiver, the behavior is undefined. */

- (void)replaceSublayer:(CALayer *)layer with:(CALayer *)layer2;

おっ?となり使うとエラー!使い方が分からないorz
Google翻訳で何となく使い方が分かるが指定方法が分からない!

で、毎度おなじみのstackoverflowで調べてみると

[self.view.layer.sublayers objectAtIndex:0]

きたこれ!

これで無事古いlayerと新しいlayerが入れ替わります。

描画スピードは多分かなりのものです。
ほぼ遅延無く描けます。が、問題はCPUの消費量がかなり上がります。
といっても普段の使い方じゃほぼ上がらないです。
花丸を念入りに描く人はご遠慮願います。

もし改善方法あるなら教えてください。
お願いします。

作り途中のコードですが、

#import "ViewController.h"

@interface ViewController ()


@property(nonatomic)UIBezierPath *bezierPath;
@property(nonatomic)UIImage *lastDrawImage;
@property(nonatomic)NSMutableArray *undoStack;
@property(nonatomic)NSMutableArray *redoStack;
@property(nonatomic)UIImageView *canvas;
@property(nonatomic)UIButton *undoBtn;
@property(nonatomic)UIButton *redoBtn;
@property(nonatomic)UIButton *clearBtn;
@property(nonatomic)UIToolbar *toolbar;


-(void)initializeMethods;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [self initializeMethods];
    [super viewDidLoad];
    self.undoStack = [[NSMutableArray alloc]init];
    self.redoStack = [[NSMutableArray alloc]init];
    
    
    
    
    // ボタンのenabledを設定します。
    self.undoBtn.enabled = NO;
    self.redoBtn.enabled = NO;
}

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
}


-(void)initializeMethods
{
    self.toolbar = [[UIToolbar alloc]initWithFrame:CGRectMake( 0, self.view.bounds.size.height - 50, self.view.bounds.size.width, 50 )];
    //space
    UIBarButtonItem *gap = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
    //gap.tintColor = [self RGBAColorWithRed:0 green:45 blue:55 alpha:1.0];
    
    //connect
    UIButton *a = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    a.frame = CGRectMake(0, 0, 50, 50);
    [a setTitle:@"undo" forState:UIControlStateNormal];
    [a addTarget:self action:@selector(undoBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    UIBarButtonItem *ab = [[UIBarButtonItem alloc]initWithCustomView:a];
    
    
    //Layer
    UIButton *b = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    b.frame = CGRectMake(0, 0, 50, 50);
    [b setTitle:@"redo" forState:UIControlStateNormal];
    [b addTarget:self action:@selector(redoBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    UIBarButtonItem *bb = [[UIBarButtonItem alloc]initWithCustomView:b];
    
    
    //Setting
    UIButton *s = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    s.frame = CGRectMake(0, 0, 50, 50);
    [s setTitle:@"clear" forState:UIControlStateNormal];
    [s addTarget:self action:@selector(clearBtnPressed:) forControlEvents:UIControlEventTouchUpInside];
    UIBarButtonItem *sb = [[UIBarButtonItem alloc]initWithCustomView:s];
    
    

    
    
    self.toolbar.tag = 2;
    
    self.toolbar.items = [NSArray arrayWithObjects:ab,gap,bb,gap,sb,nil];
    [self.view addSubview:self.toolbar];
    
    self.canvas = [[UIImageView alloc]initWithFrame:CGRectMake(0,20,self.view.bounds.size.width,self.view.bounds.size.height - 90)];
    self.canvas.image = nil;
    [self.view addSubview:self.canvas];
    
    CALayer *layer = [CALayer layer];
    layer.frame = self.canvas.bounds;
    layer.name = @"layer";
    
    [self.canvas.layer addSublayer:layer];

    
}


- (void)undoBtnPressed:(id)sender
{
    if([self.undoStack count] == 0){}else{
    
        // undoスタックからパスを取り出しredoスタックに追加します。
        UIBezierPath *undoPath = self.undoStack.lastObject;
        [self.undoStack removeLastObject];
        [self.redoStack addObject:undoPath];
    
        // 画面をクリアします。
    
        // 画面にパスを描画します。
        NSLog(@"%ld",(long)[self.undoStack count]);
    
    
        /*
    
     
         この部分を要改善
     
         childに前処理を施してから本処理を行ってください。
         全描画されてから新しい描画が行われているため変化が発生しません。
     
         */
        CALayer *child = [self.canvas.layer.sublayers objectAtIndex:0];
        CALayer *new = [[CALayer alloc]init];
        new.name = child.name;
        new.frame = self.canvas.frame;
        child = nil;
        for (UIBezierPath *path in self.undoStack) {
            UIGraphicsBeginImageContextWithOptions(self.canvas.frame.size,NO,1.0);
            // 描画領域に、前回までに描画した画像を、描画します。
            UIImage *old = [[UIImage alloc]initWithCGImage:(CGImageRef)new.contents];
            [old drawAtPoint:CGPointZero];
            // 色をセットします。
            [[UIColor blackColor] setStroke];
            // 線を引きます。
            [path stroke];
            // 描画した画像をcanvasにセットして、画面に表示します。
            new.contents = (id)[UIGraphicsGetImageFromCurrentImageContext() CGImage];
            // 描画を終了します。
            UIGraphicsEndImageContext();
        }
        [self.canvas.layer replaceSublayer:[self.canvas.layer.sublayers objectAtIndex:0] with:new];
        // ボタンのenabledを設定します。
        self.undoBtn.enabled = (self.undoStack.count > 0);
        self.redoBtn.enabled = YES;
    }
}

- (void)redoBtnPressed:(id)sender
{
    // redoスタックからパスを取り出しundoスタックに追加します。
    if([self.redoStack count] == 0){}else{
        UIBezierPath *redoPath = self.redoStack.lastObject;
    
        [self.redoStack removeLastObject];
        [self.undoStack addObject:redoPath];
    
        // 画面にパスを描画します。
        CALayer *child = [self.canvas.layer.sublayers objectAtIndex:0];
        CALayer *new = [[CALayer alloc]init];
        new.name = child.name;
        new.frame = self.canvas.frame;
        child = nil;
        for (UIBezierPath *path in self.undoStack) {
            UIGraphicsBeginImageContextWithOptions(self.canvas.frame.size,NO,1.0);
            // 描画領域に、前回までに描画した画像を、描画します。
            UIImage *old = [[UIImage alloc]initWithCGImage:(CGImageRef)new.contents];
            [old drawAtPoint:CGPointZero];
            // 色をセットします。
            [[UIColor blackColor] setStroke];
            // 線を引きます。
            [path stroke];
            // 描画した画像をcanvasにセットして、画面に表示します。
            new.contents = (id)[UIGraphicsGetImageFromCurrentImageContext() CGImage];
            // 描画を終了します。
            UIGraphicsEndImageContext();
        }
        [self.canvas.layer replaceSublayer:[self.canvas.layer.sublayers objectAtIndex:0] with:new];
    
        // ボタンのenabledを設定します。
        self.undoBtn.enabled = YES;
        self.redoBtn.enabled = (self.redoStack.count > 0);
    }
}

- (void)clearBtnPressed:(id)sender
{

    // 保持しているパスを全部削除します。
    [self.undoStack removeAllObjects];
    [self.redoStack removeAllObjects];
    
    // 画面をクリアします。

    self.canvas.layer.sublayers = nil;
    CALayer *layer = [CALayer layer];
    layer.frame = self.canvas.bounds;
    layer.name = @"layer";
    
    [self.canvas.layer addSublayer:layer];
    
    // ボタンのenabledを設定します。
    self.undoBtn.enabled = NO;
    self.redoBtn.enabled = NO;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    // タッチした座標を取得します。
    CGPoint currentPoint = [[touches anyObject] locationInView:self.canvas];
    self.bezierPath = [UIBezierPath bezierPath];
    self.bezierPath.lineCapStyle = kCGLineCapRound;
    self.bezierPath.lineWidth = 5.0;
    [self.bezierPath moveToPoint:currentPoint];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    // タッチ開始時にパスを初期化していない場合は処理を終了します。
    if (self.bezierPath == nil){
        return;
    }
    
    // タッチした座標を取得します。
    CGPoint currentPoint = [[touches anyObject] locationInView:self.canvas];
    // パスにポイントを追加します。
    [self.bezierPath addLineToPoint:currentPoint];
    // 線を描画します。
    UIGraphicsBeginImageContextWithOptions(self.canvas.frame.size,NO,1.0);
    CALayer *child = [self.canvas.layer.sublayers objectAtIndex:0];
    CALayer *new = [CALayer layer];
    new.frame = self.canvas.frame;
    new.name = child.name;
    // 描画領域に、前回までに描画した画像を、描画します。
    
    UIImage *old = [[UIImage alloc]initWithCGImage:(CGImageRef)child.contents];
    [old drawAtPoint:CGPointZero];
    
    // 色をセットします。
    [[UIColor blackColor] setStroke];
    
    // 線を引きます。
    [self.bezierPath stroke];
    
    // 描画した画像をcanvasにセットして、画面に表示します。
    new.contents = (id)[UIGraphicsGetImageFromCurrentImageContext() CGImage];
    [self.canvas.layer replaceSublayer:[self.canvas.layer.sublayers objectAtIndex:0] with:new];
    // 描画を終了します。
    UIGraphicsEndImageContext();
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    // タッチ開始時にパスを初期化していない場合は処理を終了します。
    if (self.bezierPath == nil){
        return;
    }
    
    // タッチした座標を取得します。
    CGPoint currentPoint = [[touches anyObject] locationInView:self.canvas];
    
    // パスにポイントを追加します。
    [self.bezierPath addLineToPoint:currentPoint];
    
    // 線を描画します。
    UIGraphicsBeginImageContextWithOptions(self.canvas.frame.size,NO,1.0);
    CALayer *child = [self.canvas.layer.sublayers objectAtIndex:0];
    CALayer *new = [CALayer layer];
    new.frame = self.canvas.frame;
    new.name = child.name;
    // 描画領域に、前回までに描画した画像を、描画します。
    
    UIImage *old = [[UIImage alloc]initWithCGImage:(CGImageRef)child.contents];
    [old drawAtPoint:CGPointZero];
    
    // 色をセットします。
    [[UIColor blackColor] setStroke];
    
    // 線を引きます。
    [self.bezierPath stroke];
    
    // 描画した画像をcanvasにセットして、画面に表示します。
    new.contents = (id)[UIGraphicsGetImageFromCurrentImageContext() CGImage];
    [self.canvas.layer replaceSublayer:[self.canvas.layer.sublayers objectAtIndex:0] with:new];
    // 描画を終了します。
    UIGraphicsEndImageContext();
    
    // 今回描画した画像を保持します。
    
    // undo用にパスを保持して、redoスタックをクリアします。
    [self.undoStack addObject:self.bezierPath];
    [self.redoStack removeAllObjects];
    
    
    // ボタンのenabledを設定します。
    self.undoBtn.enabled = YES;
    self.redoBtn.enabled = NO;
    
    self.bezierPath = nil;
}

@end

原型は留めておりません!
redoとundoの処理は実装していません。実装完了しました。
あとdrawLineメソッドを通すとcpuの消費が跳ね上がる為にここで処理を施す事でかなり抑えれます。


ここにアクセスしてくれる方が多いので一応動くデモをgithubに置いときました。
GitHub - mokako/iOS: iOS APP
のoekakiで登録しています。ちなみにピッカーは気に入ってるので色を探す時に使っていますw


追記
もう少し詳細な内容を書きましたのでそちらもどうぞ
pico-bit.hatenablog.com