Languages/Objective-C2011.01.20 17:35
UIScrollView를 사용하여 Zooming, 페이지 단위 스크롤링을 고려하려면 주의해야 할 것이 있습니다. 그건 바로 "하나의 UIScrollView 하위 클래스에 Zooming 기능과 스크롤링 관련 기능을 동시에 우겨넣으면 안된다"는 것이죠.

같이 우겨넣어 프로그래밍을 할 수 있을지도 모르겠습니다만, 아마 "굉장히" 프로그래밍하기 어려울 겁니다.

그럼 확대/축소도 되고 좌우로 스크롤링도 되는 PDF 뷰어 같은걸 구현하려면 어떻게 해야 하나요?

가장 간단한 방법은 스크롤 뷰를 중첩(nesting)하는 겁니다. 안쪽 스크롤 뷰에는 확대/축소 기능을 넣고, 바깥쪽 스크롤 뷰에는 좌우 스크롤 기능을 구현하는 것이죠.

확대 축소 기능에 대해서는 지난번 글에 잠깐 다루었는데, 좌우 스크롤 기능과 함께 사용하려면 지난번 글처럼 기능을 무조건 단순화 하면 곤란합니다. 좌우 스크롤하려면 다음과 같은 요구사항을 만족해야 하기 때문이죠.

(1) 끊김없는 좌우 스크롤
(2) 다음번에 나타날 화면의 caching
(3) 페이지 단위 좌우 스크롤

(3)번은 만족시키기가 간단합니다. 스크롤 뷰의 pagingMode를 YES로 설정하면 되거든요. 그렇게 하면 화면 크기를 (엄밀하게 말하면 스크롤 뷰의 bounds 속성 값) 페이지 크기로 인식하고 스크롤을 합니다.

(1)을 할떄는 생각할 것이 좀 많습니다. '끊김 없는'이라는 것은 '좌우 스크롤을 할 때 멈칫 거리는 일이 없어야 함'을 의미하거든요. 모든 페이지를 하나의 스크롤 뷰의 서브 뷰로 몽땅 다 로딩해 버린다음에 스크롤 하면 별문제겠습니다만 그렇게 하는 건 오버헤드가 너무 큽니다. 그러니 보통은 좌우 스크롤을 구현할 때 이렇게 해야 하죠.

a. 스크롤 뷰의 contentSize는 스크롤 뷰 안에 '모든 페이지'를 우겨넣는다고 가정하고 잡습니다.

그래야 하는 이유는? 그래야 스크롤 뷰가 '어디서부터 어디까지 스크롤 해야 하는지' 제대로 인식할 수 있기 때문이죠.

b. 스크롤 뷰 안에 컨텐트를 (그러니까 서브 뷰들을) 추가할 때에는 딱 세 개 (혹은 다섯 개?) 만 합니다.

화면에 보이는 것은 그 중 가운데고, 왼쪽 오른쪽에 있는 것들은 각각 손가락을 오른쪽으로 긁었을 때, 왼쪽으로 긁었을 때 화면에 나타나야 하는 것들이죠.

c. 스크롤 뷰가 실제로 스크롤 되면, 위의 세 개 뷰 중에 왼쪽에 있는 것을 오른쪽으로 옮기고, 가운데 있는 것은 오른쪽, 맨 오른쪽에 있는 것은 폐기처분하거나 (release) 아니면 맨 왼쪽으로 돌려서 재활용합니다. (재활용 할 때에는, 재활용 한 뷰에 '그려졌던' 내용이 잠시라도 화면에 다시 표시되는 일이 없도록 주의해야 합니다.)

이렇게만 하면 서브뷰 딱 세 개만 가지고도 가로 방향 스크롤을 구현할 수 있습니다. (세로 방향도 마찬가지.) 굉장히 간단하게 들리는데, 무슨 문제가 있나요?

보통 '왼쪽으로 스크롤이 일어났다' 아니면 '오른쪽으로 스크롤이 일어났다'는 뭘 가지고 판단하나요? 스크롤 뷰의 delegate 함수 중에 scrollViewDidEndDecelerating 함수를 구현해서 할 수도 있겠지만, 테스트 해 본 바에 따르면 굉장히 페이지를 빠른 속도로 넘기는 경우에 문제가 발생할 가능성이 있습니다.

그래서 보통은 스크롤 뷰의 contentOffset.x 값이 오른쪽이나 왼쪽 페이지의 중간 지점을 넘어갔을 때 '스크롤이 일어났다'고 판단하게 되는데, 그러면 그 때 무슨 일을 해야 하느냐 하면 앞서 말한 대로 페이지들의 위치를 옮기고 재배치하는 등의 작업을 해 줘야 하거든요.

근데 재배치 될 뷰 안의 drawRect나 drawLayer 함수 안에서 뭔가 굉장히 많은 일을 하게 되면 바로 그 순간에 스크롤이 멈칫거리는 현상이 발생하게 됩니다. (Apple의 이미지 스크롤 관련 예제에서도 동일한 문제가 발생해요.) PDF 페이지를 렌더링 하는 것도 PDF 파일에 따라서는 막대한 오버헤드를 유발하기 때문에 마찬가지죠.

이 문제를 극복하기 위해서는 drawRect는 가급적 피하고 drawLayer를 쓰며, layer 클래스로는 CATiledLayer를 쓰는 것이 바람직합니다. CATiledLayer는 렌더링을 할 때 쓰레드를 만들어서 백그라운드에서 렌더링을 하기 때문에, drawLayer가 스크롤을 멈칫거리게 한다던가 하는 일이 적어요.

물론 화면에 뜨는 스피드는 좀 느려지긴 합니다. 이런 trade-off에 대해서는 개발자가 적절히 생각하고 선택을 해야겠죠. (저는 못해 드립니다. ㅎㅎ)
신고
Posted by 이병준

소중한 의견, 감사합니다. ^^

Languages/Objective-C2011.01.17 16:18
보통 Zoom 효과를 구현할 때 많이 사용하는 것이 UIScrollView입니다. 특히 PDF의 확대 효과에 관해서라면 Apple Developer Center에서 제공하는 ZoomingPDFViewer 예제를 많이들 참고하시죠.

이 예제에서는 세 개의 뷰를 사용해서 Zoom 효과를 구현합니다. 일단 백그라운드에 pdf를 먼저 그리구요. 그 앞에 zooming을 구현할 pdf view 두 개를 둡니다. 하나는 확대 이전 뷰, 하나는 확대 다음 뷰. (간단히 설명하자면 그렇습니다) zooming이 진행중인 동안에는 확대 이전 뷰를 사용해서 rendering하다가, 확대가 끝나고 나면 확대 다음 뷰를 확대된 크기만큼 새로 만들어서 (그러기 위해서는 얼마나 확대되었는지를 기억하고 있어야 합니다) 뷰가 보이는 계층 맨 위쪽에 붙여 버리는 것이죠. 

그런데 이 세 개의 뷰가 정말 필요한 걸까요? 이건 ZoomingPDFViewer 예제를 처음 봤을때 제 스스로 던진 질문이었습니다. 이 문제를 풀기 위해서, 과연 zooming이 진행 중일때 scroll view의 content view에 해당하는 뷰 (그러니까 확대 대상인 뷰)에 어떤 일이 벌어지는 지를 살펴봤습니다.

결론부터 이야기하자면, content view의 frame 속성(CGRect 값입니다)이 끊임없이 변합니다. 하지만 content view의 bounds 속성 값은 전혀 변하지 않습니다. 그러니, 다음과 같이 했다고 해 봅시다.

- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView

{

    return pdfView;

}


위의 코드는 확대 대상 content view를 scroll view에게 반환하기 위해 구현하는, UIScrollViewDelegate 프로토콜 함수의 코드입니다. pdfView는 self.bounds를 사용해 자기 뷰 내부를 렌더링합니다. 그런데 frame 객체의 크기는 zooming을 할수록 자꾸 커진단 말입니다. 그래도 self.bounds에는 변함이 없으니, 뷰가 커지면 커질수록 뷰 안에 그려진 내용은 해상도가 떨어져 보이게 됩니다. 

그럼 대체 scroll view는 self.bounds는 가만 냅두면서 self.frame은 어떻게 계속 뻥튀기하는 걸까요?

답은 pdfView의 (그러니까 UIView를 상속한 뷰 클래스 객체라면 누구나 갖고 있는) transform 프라퍼티에 숨어 있습니다. 이 프라퍼티의 값이 CGAffineTransformIdentity가 아닐 경우, frame 프라퍼티의 값은 계산된 값입니다. 보통은 frame이나 bounds나 값이 동일하지만, transform 프라퍼티의 값이 CGAffineTransformIdentity가 아닌 경우에는 frame은 bounds 프라퍼티에 transform을 적용해서 구해진 CGRect 값이 되는 것이죠.

그래서 다음과 같은 코드 안에서 frame의 값과 bounds의 값을 찍어보면 그 값이 서로 다르게 나옵니다.

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(float)scale

{

// ...

NSLog(@"scale = %f, pdfScale = %f", scale, pdfScale);

NSLog(@"frame = %f, %f, %f, %f", view.frame.origin.x, view.frame.origin.y, view.frame.size.width, view.frame.size.height);

NSLog(@"bounds = %f, %f, %f, %f", view.bounds.origin.x, view.bounds.origin.y, view.bounds.size.width, view.bounds.size.height);

// ...

}


그러니까 결국 UIScrollView는 content view의 transform 프라퍼티의 값을 적절히 변경해서, content view의 확대/축소를 구현한다는 결론을 내릴 수 있는 것이죠. (거기다 contentOffset 프라퍼티까지 함께 수정/적용해서 말이죠.)

자. 그럼 여벌의 view 없이도 확대 동작을 구현하려면, ZoomingPDFViewer의 코드를 어떻게 개선해야 하는 걸까요? transform 프라퍼티를 적당히 매만지도록 코드를 변경하면 되겠군요. 지금부터는 여러분께서 ZoomingPDFViewer의 코드를 같이 보고 계신다고 가정하고 설명을 진행하겠습니다. 우선, backgroundImageView를 scroll view의 서브 뷰로 붙이는 부분을 아예 없애 버립니다. 필요 없으니까요.

그런 다음, oldPDFView를 참조하는 코드도 전부 없애 버립니다. 역시 필요 없습니다. (그러면 아마 scrollViewWillBeginZooming 안의 코드가 전부 날아가 버릴겁니다.) 

자. 그런 다음에는 pdfView만 필요하니까, PDFScrollView의 initWithFrame 메소드 안에서 pdfView를 생성하는 부분의 코드를 다음과 같이 고칩니다.

pdfView = [[TiledPDFView alloc] initWithFrame:pageRect andScale:pdfScale];

[pdfView setPage:page];

[self addSubview:pdfView];


이렇게 한 다음에, scrollViewDidEndZooming의 코드를 다음과 같이 고칩니다.

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView 

         withView:(UIView *)view atScale:(float)scale

{

// set the new scale factor for the TiledPDFView

pdfScale *=scale;

pdfView.bounds = CGRectMake(0,0,

                      view.frame.size.width, view.frame.size.height);

        // make the frame size to the bounds size

view.transform = CGAffineTransformIdentity

       

[pdfView setNeedsDisplay];

}


그런 다음, TiledPDFView의 initWithFrame의 코드를 다음과 같이 수정합니다.

tiledLayer.levelsOfDetail = 1;

tiledLayer.levelsOfDetailBias = 1;

tiledLayer.tileSize = CGSizeMake(512, 512);


detail 레벨을 1로 주어야 줌이 된 다음에 해상도가 상승되는 것이 보이게 되더군요. 너무 높으면 마치 줌이 되는 동안에서 해상도가 상승되다가, 마지막에 뷰가 쓸데없이 다시 그려지는 것 처럼 보입니다.

이렇게 한 다음에 실행시켜 보면, 이제 뷰 하나만 가지고도 PDF 문서의 zooming, 그러니까 확대/축소가 정상적으로 처리됩니다. scrollViewDidZooming 안에서 bounds의 값이 frame과 같도록, transform 프라퍼티의 값을 강제로 CGAffineTransformIdentity로 변경했기 때문입니다. 

자. 그런데 한가지 문제가 남았습니다. 뭔가요?

네. 아마 이 예제를 한번이라도 실행해 본 분이라면 아실텐데요. 이 문제는 이전 버전이나 현 버전이나 똑같습니다. 이 문제는 content view의 크기가 계속 변하기 때문에 발생하는 문제입니다. content view가 예전과 동일한 instance를 가리키고 있고 bounds의 값에도 변화가 없는 경우에는 scrollViewDidEndZooming 메소드의 atScale:(float)scale 인자값 'scale'의 값이 항상 'content view의 원래 크기 대비, 현재 크기의 비율'을 나타내게 되는데, content view instance가 확대할 때 마다 계속 달라지거나 (종전 예제의 경우) bounds의 값이 똑같이 유지되지 않는 경우(현 예제의 경우)에는 인자값 'scale'의 값이 '종전 크기 대비, 현재 크기의 비율'을 나타내게 된다는 것이죠. 

따라서 scroll view의 maximumZoomScale 프라퍼티나 minimumZoomScale 프라퍼티 값을 제한하는 것으로는 '어디까지 확대하고 어디까지 축소할 것인지'를 결정할 수 없게 됩니다. 

그러니 scrollViewWillBeginZooming과 scrollViewDidEndZooming 메소드 안에서 pdfScale 값을 가지고 minimumZoomScale 프라퍼티와 maximumZoomScale 프라퍼티를 가지고 꽁수를 좀 부려야 됩니다. 

이에 관해서는 각자 알아서 해보시는 것으로 하고, (연습문제라고 해도 좋겠습니다. ㅎㅎ) ZoomingPDFViewer 예제 분석과 개선에 관한 이번 글은 마치도록 하죠. 

신고
Posted by 이병준

소중한 의견, 감사합니다. ^^

  1. 띵호

    좋은 글 잘 읽었습니다.

    님이 남겨주신 문제가 딱 제가 가지고 있는 문제네요 ㅋㅋㅋㅋ
    근데 pdfScale를 가지고 계속 씨름 중인데. 혹시..아닌가요??ㅜㅜ

    2012.05.29 02:27 신고 [ ADDR : EDIT/ DEL : REPLY ]
    • 띵호

      와우 거의 한시간 동안 씨름해봤는데....
      문제를 해결했습니다.
      머가 실마리인지 몰랐는데 minimumzommScale 과 maximumZoomScale 를 가지고 노니 해결되네요 ㅋㅋ

      감사합니다 수고하세요~~

      2012.05.29 03:00 신고 [ ADDR : EDIT/ DEL ]

Languages/Objective-C2011.01.11 15:18
아이패드 보급이 늘어가면서 아이패드 위에 PDF 뷰어같은걸 구현하시는 분들도 많이 늘어가는 것 같군요. 그런 소프트웨어 구현할 때 가장 많이 사용하게 되는 것이 CGContextDrawPDFPage라는 함수죠. 이 함수는 현재의 드로잉 컨텍스트 위에 PDF 페이지를 뿌립니다.

이 함수의 한가지 문제는 호출할 때 마다 굉장히 많은 메모리를 계속 잡아먹는다는 점이죠. 그래서 PDF 파일을 열어놓은 상태로 모든 페이지를 전부 다 보게 되는 경우, 메모리 관련 경고가 뜨게 될 수 있습니다.

이 문제를 해결하는 한가지 방법은 메모리 점유율이 증가하거나 메모리 관련 경고가 뜨는 경우 (그러니까 다시 말해 메인 View Controller의 didReceiveMemoryWarning이 호출되는 경우) PDF 파일을 닫았다가 다시 여는 것이 되겠습니다. 

그런데 그런 코드를 작성하려면 PDF 파일에 대한 레퍼런스 (CGPDFDocumentRef)를 들고 있는 클래스를 어디 한 군데로 제한하는 것이 바람직하죠. 그럴 때에는 Singleton 패턴을 써야 한다는 것은 아마 많은 분들이 알고 계시겠습니다만...

그런데 그냥 CGPDFDocumentRef만 singleton 패턴으로 관리하면 땡인걸까요?

그렇지 않습니다. 한 쪽에서 이 패턴 객체를 통해 레퍼런스를 가져가서 열심히 뭔가 작업을 하고 있는데, 다른 한쪽에서 이 레퍼런스를 닫아버린다거나 하게 되면 프로그램이 당장 돌아가시게 되죠. 

그러니까 결국은 Singleton 패턴을 통해 CGPDFDocumentRef에 대한 접근 자체를 통제해야 합니다. 몇 가지 방법이 있겠습니다. 레퍼런스 카운터를 통해서 모두가 document ref를 전부 다 썼다는 것이 보장되기 전까지는 닫지 않는다거나 하는 것도 한가지 방법이죠.

저는 메모리를 얼마 이상 쓰지 않아야 한다는 요구조건이 있어서, 다음과 같은 싱글턴 클래스를 만들고 block을 통해서 해당 PDF 문서에 대한 작업을 구현하도록 하는 클래스를 만들어서 쓰고 있습니다. 소스코드를 보시면...

//

//  PDFSingleton.h

//  Muine

//

//  Created by bjlee on 11. 1. 11..

//  Copyright 2011 buggymind.com. All rights reserved.

//


#import <Foundation/Foundation.h>



@interface PDFSingleton : NSObject {


}


+ (NSUInteger) do:(NSUInteger(^)(CGPDFDocumentRef))blk;


@end



//

//  PDFSingleton.m

//  Muine

//

//  Created by bjlee on 11. 1. 11..

//  Copyright 2011 buggymind.com. All rights reserved.

//


#import "PDFSingleton.h"



@implementation PDFSingleton


static CGPDFDocumentRef _pdf = NULL;

static int requestCount = 0;


+ (NSUInteger) do:(NSUInteger(^)(CGPDFDocumentRef))blk {


@synchronized ( self ) {

if ( ++requestCount == 6 ) {

CGPDFDocumentRelease(_pdf);

_pdf = NULL;

requestCount = 1;

}

if ( _pdf == NULL ) {

CFURLRef pdfURL = CFBundleCopyResourceURL(CFBundleGetMainBundle(), CFSTR("xxx.pdf"), NULL, NULL);

_pdf = CGPDFDocumentCreateWithURL((CFURLRef)pdfURL);

CFRelease(pdfURL);

}


return blk(_pdf);

}

}



@end


PDF 문서 페이지가 여섯번 이상 요청되면 강제로 닫았다가 다시 열도록 했구요. 해당 pdf 문서에 대한 사용자 작업은 block으로 처리했습니다. 사용 예를 보시면...

// ctx의 선언은 이 위 어딘가에 있음 ㅎ

[PDFSingleton do:^(CGPDFDocumentRef pdf) {

CGPDFPageRef page = CGPDFDocumentGetPage(pdf, index);

// ...

CGContextDrawPDFPage(ctx, page);

return (NSUInteger)0;

}];


그런데 이 코드에는 문제가 있습니다. 뭘까요?

네. 어떤 한 쓰레드에서 해당 PDF 파일을 사용해 뭔가 심각하고 오래 걸리는 일을 하고 있으면, 다른쪽에서는 그 일이 끝날 때 까지 무작정 기다려야 합니다.

그러니 동시성을 활용해 캐싱이라던가 프리패칭(pre-fetching)같은 것을 하고 싶다면, 이렇게 하면 곤란하겠죠. 연습문제 삼아, 각자 이 문제를 고민해보시기 바랍니다. :-)
신고
Posted by 이병준

소중한 의견, 감사합니다. ^^