October 06, 2011

วาดกราฟใน iOS ด้วย CoreGraphic

เห็นหลายๆ App มีการวาดกราฟสวยๆ กัน โดยเฉพาะ App เกี่ยวกับหุ้นเช่น App ของ Bloomberg เป็นต้น จึงลองวาดเล่นดูสักหน่อย ถือเป็นการทบทวนทักษะทางด้าน CoreGraphic ก็แล้วกัน เพราะตั้งแต่ทำงานมาแทบไม่ได้ใช้ทักษะทางด้านนี้เลย

โดยรูปเป้าหมายที่ต้องการ จะมีหน้าตาดังนี้



หลักการการวาดรูปต่างๆ ลงไปก็คือเราจะเข้าไป override method  - (void)drawRect:(CGRect)rect ของ UIView และสั่งวาดสิ่งต่างๆ ข้างใน method นี้

เมื่อเราต้องการวาดอะไรลงไปก็ตาม เราต้องการสิ่งที่เรียกว่า context และเพื่อให้เข้าใจได้ง่ายๆ ให้มองว่า context ตัวนี้ก็คือผืนผ้าใบที่เราจะวาดสิ่งต่างๆ ลงไปนั่นเอง ดังนั้นเมื่อเราจะเริ่มทำอะไร เราต้องได้ context มาก่อน เพื่อเอาไว้ใช้อ้างอิงในภายหลัง ว่าเราจะทำอะไร จะวาดอะไรลงไปใน context (ผืนผ้าใบ) อันนี้ (context เป็น stateful ซึ่งแปลว่าหากเรากำหนดค่าอะไรให้มันไปแล้ว มันก็จะยังคงค่านั้นเสมอ จนกว่าเราจะเปลี่ยนแปลงค่าให้มันเองในภายหลัง) ดังนี้


    // get current context
    CGContextRef context = UIGraphicsGetCurrentContext();


เริ่มจากการวาดเส้นประ (dash) ที่เป็นพื้นหลังของกราฟก่อน เริ่มวาดเส้นประ (dash) โดยกำหนดความกว้างของเส้น สีที่จะวาด ลักษณะของเส้นประ ดังนี้

    // draw bg line
    CGContextSaveGState(context);
    CGContextSetLineWidth(context, 1.0);
    CGContextSetStrokeColorWithColor(context, [UIColor lightGrayColor].CGColor);
    CGFloat dashArray[] = {4,4,4,4};
    CGContextSetLineDash(context, 3, dashArray, 4);

เมื่อเรากำหนดลักษณะของเส้นที่จะวาดเรียบร้อยแล้ว ต่อไปก็กำหนดเส้นที่จะวาด ว่าจะวาดเส้นโดยเริ่มจากจุดไหน ไปถึงจุดไหนดังนี้

    for(int i = 50; i <= 300; i += 50){
        CGContextMoveToPoint(context, i, 200);
        CGContextAddLineToPoint(context, i, 480);
        
        CGContextMoveToPoint(context, 0, 200 + i);
        CGContextAddLineToPoint(context, 320, 200 + i);
    }


เมื่อกำหนดเส้นที่จะวาดเรียบร้อยแล้ว ก็สั่งให้วาดลงไปใน context (ผืนผ้าใบ) ด้วยฟังก์ชั่น CGContextStrokePath(context) ได้เลย

    CGContextStrokePath(context);
    CGContextRestoreGState(context);


สำหรับความหมายของ CGContextSaveGState(context) และ CGContextRestoreGState(context) ให้อ่านได้จากบทความนี้ สั้นๆ ก็คือมันเป็นการบันทึกสถานนะของการกำหนดค่าต่างๆ ให้กับ context หลังจากนั้นเมื่อเราทำอะไรเสร็จแล้ว เราก็จะย้อนกลับไปสถานะของ context ก่อนหน้าที่เราจะทำอะไรกับมันด้วยการ restore context นั่นเอง เมื่อเราสั่งให้วาดเส้นประแล้วเราจะได้ผลลัพธ์ดังนี้





ถ้าหากเราสั่ง gradient ลงไปใน context ตอนนี้ เราจะได้ gradient เต็มหน้าจอเนื่องจาก context ของเราจะมีรูปร่างเป็นสี่เหลี่ยมผืนผ้าเต็มหน้าจอนั่นเอง แต่ที่เราต้องการจริงๆ ก็คือเราต้องการให้ gradient เฉพาะพื้นที่ใต้กราฟเท่านั้น ซึ่งเทคนิคที่เราจะใช้ก็คือ เราจะสร้าง shape ที่เป็นพื้นที่ใต้กราฟ จากนั้นเราจะ clip context ให้เป็นรูปเดียวกับ shape ที่เราวาดขึ้นมา จากนั้นจึงจะวาด gradient ลงไปใน context ที่ clip แล้ว

สมมติว่าเรามีข้อมูลอยู่ 40 ชุด โดยเราสามารถ generate ข้อมูล 40 ชุดขึ้นมาได้ดังนี้ก่อน


    // generate 40 data, each limited to 150
    int r = 0;
    int array[40];
    for(int i = 0; i <= 40; i ++){
        r = arc4random() % 150;
        array[i] = r;
    }


เก็บ data ไว้ในตัวแปร array ขนาด 40 ต่อไปเราจะวาด Shape ที่ที่เป็นพื้นที่ใต้กราฟโดยอิงจากข้อมูล 40 ชุดที่เรามี ดังนี้


    // draw graph by points stored in array
    CGContextSaveGState(context);
    CGContextMoveToPoint(context, 0, array[0] + graphBase);
    for(int i = 0; i <= 40; i ++){
        CGContextAddLineToPoint(context, i * 8, array[i] + graphBase);
    }
    
    // wrap it as a shape
    CGContextAddLineToPoint(context, 320, 480);
    CGContextAddLineToPoint(context, 0, 480);
    CGContextAddLineToPoint(context, 0, array[0] + graphBase);
    CGContextSetLineWidth(context, 3);
    CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
    CGContextStrokePath(context);
    CGContextRestoreGState(context);



เหมือนกับการวาดเส้นประในตอนแรก โดยเราจะวนลูปวาดเส้นไปทีละจุดๆ ใน array เมื่อวาดครบทุกจุดแล้วเราจะวาดย้อนกลับมายังจุดเริ่มต้นใหม่อีกครั้งให้เป็น Shape ปิด และเมื่อเราทดลองวาดเส้นเพื่อตรวจสอบ Shape ที่เราวาดขึ้นมา เราจะได้ Shape หน้าตาดังนี้



Shape ถูกวาดเส้นด้วยสีแดง ต่อไปเราจะทำการ clip Context ด้วย Shape ที่เราวาดขึ้นมาโดยใช้ฟังก์ชั่น CGContextClip(context) และเอาโค้ดในการวาดเส้นสีแดงให้ Shape ออกไป ทำให้จากโค้ดด้านบนเราจะเหลือแบบนี้


    // wrap it as a shape
    CGContextAddLineToPoint(context, 320, 480);
    CGContextAddLineToPoint(context, 0, 480);
    CGContextAddLineToPoint(context, 0, array[0] + graphBase);
    CGContextSetLineWidth(context, 3);
    CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
    CGContextClip(context);


ต่อไปจะวาด Gradient ลงไปใน context ที่เหลือจากการถูก clip โดยเริ่มจากการนิยาม Gradient ก่อนว่าจะให้มีลักษณะเป็นอย่างไร


    // gradient definition
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGFloat locations[] = { 0.55, 1.0};
    CGColorRef endColor = [UIColor colorWithRed:0.0f 
                                          green:153.0f/256.0f 
                                           blue:249.0f/256.0f 
                                          alpha:1.0].CGColor;
    
    CGColorRef startColor = [UIColor colorWithRed:1
                                            green:1
                                             blue:1
                                            alpha:0.5].CGColor;
    
    NSArray *colors = [NSArray arrayWithObjects:(id)startColor, (id)endColor, nil];
    CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (CFArrayRef) colors, locations);
    CGPoint startPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMinY(rect));
    CGPoint endPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMaxY(rect));


โค๊ด เป็นการกำหนด color space (ส่วนใหญ่แทบทั้งหมดจะใช้ RGB)  กำหนดตำแหน่งของสีที่จะใช้วาด gradient กำหนดสีเริ่มต้น และสีปลายของ gradient กำหนดจุดเริ่มต้นของ gradient และจุดปลายของ gradient

วาด gradient ลงไปใน context ที่ clip ไปแล้ว และ restore state เดินกลับมาแบบนี้


    CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, 0);
    CGContextRestoreGState(context);


จะได้ผลลัพธ์ดังนี้



เราได้ gradient ใต้กราฟมาแล้ว ต่อไปเราจะวาดเส้นกันล่ะ โดยจะวนลูปวาดเส้นจากข้อมูลทั้ง 40 จุดที่เรามี ดังนี้


    // draw graph line
    CGColorRef graphColor = [UIColor colorWithRed:0.0f 
                                            green:153.0f/256.0f 
                                             blue:249.0f/256.0f 
                                            alpha:1].CGColor;
    CGContextSaveGState(context);
    CGContextSetStrokeColorWithColor(context, graphColor);
    CGContextSetLineWidth(context, 3);
    CGContextMoveToPoint(context, 0, array[0]);
    for(int i = 0; i <= 40; i ++){
        CGContextAddLineToPoint(context, i * 8, array[i]);
    }
    CGContextStrokePath(context);
    CGContextRestoreGState(context);


กำหนดสีที่จะวาด จากนั้นลูปวาดเส้นตามจุดต่างๆ ตามข้อมูลทั้ง 40 จุดที่เรามี เราก็จะได้กราฟหน้าตาแบบนี้ออกมาแล้ว



ส่วนการวาด text หรือข้อความลงไปด้วย CoreGraphic นั้น ถ้าหากเราสั่งวาดแบบปกติลงไป เราจะได้ข้อความที่กลับหัว ต้อง transform มันด้วยโดยการกำหนด text matrix ก่อนการสั่งวาด ดังนี้


    // draw text
    char* text ="Apple API";
    CGContextSelectFont(context, "Helvetica", 16, kCGEncodingMacRoman); 
    CGContextSetTextDrawingMode(context, kCGTextFill);
    CGContextSetRGBFillColor(context, 1, 1, 1, 1);
    
    CGAffineTransform xform = CGAffineTransformMake(1.00.0,
                                                    0.0, -1.0,
                                                    0.00.0);
    CGContextSetTextMatrix(context, xform);
    CGContextShowTextAtPoint(context, 120, 450, text, strlen(text));



ซอสโค้ดของตัวอย่างนี้ดาวโหลดได้ที่นี่ หรือดาวน์โหลดจาก GitHub ก็ได้

อ้างอิง:
An iOS 4 iPad Graphics Drawing Tutorial using Quartz 2D
Quartz 2D Programming Guide