KLab若手エンジニアの これなぁに?

KLab株式会社の若手エンジニアによる技術ブログです。phpやRubyなどの汎用LLやJavaScript/actionScript等のクライアントサイドの内容、MySQL等のデータベース、その他フレームワークまで幅広く面白い情報を発信します。

e-book

iPadで動く電子書籍アプリを作ってみる(4)

初めに

あけましておめでとうございます。ponpoko1968です。昨年中はご愛読ありがとうございました。2011年も若手ブログをよろしくお願いいたします。 さて、今回はズーム機能の説明です。

通常のズーム機能

これまでの記事で何度かご説明したように、今回のアプリでは、文書のページイメージを表示するビューをUIScrollViewのサブビューとするようビューの階層構造を作っています。 これは、UIScrollViewのズーム機能を活用することが目的の一つでした。 早速、ページ表示画面にズーム機能を追加してみましょう。 UIScrollVeiwは、UIScrollViewDelegateというプロトコルをサポートしており、UIScrollViewに対してユーザが操作を行うタイミングの節目節目でこのプロトコルで定義されているメソッドをデリゲートに対して送ります。 デリゲートとなるオブジェクトは必要なデータを返したり、アプリの状態をデリゲートメソッドが呼ばれたタイミングに適したものにする処理を行うことが出来ます。 iOSプログラミングでは、クラスの継承によってクラスをカスタマイズさせるのではなく、デリゲートパターンを用いてクラスの一部処理を別のクラスに委譲(delegation)させる手法がよく用いられています。 このアプリの場合、ページ表示画面の動作を全体的に制御しているPageViewContollerをUIScrollViewのデリゲートに設定します。 PageViewContollerクラスの定義で、UIScrollViewDelegateをサポートすることを宣言します。

  PageViewContoller.h
  @interface PageViewController : UIViewController {
次に、InterfaceBuilderで、UIScrollViewのdelegateプロパティにPageViewContollerのインスタンスを指定します。これで、UIScrollViewはUIScrollViewDelegateのメソッドをPageViewContollerに送るようになります。 スクロール動作を始めたときにUIScrollViewがズーム表示する対象となるビューをUIScrollViewに教える処理を実装します。

  - (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView{
    return contentView_;
  }

拡大してスクロール操作をしようとしているのに、タップと見なされてページ遷移してしまうのを防ぐため、拡大中はタップの認識を抑制するようにします。 拡大操作の開始タイミングは先ほどのviewForZoomingInScrollView:が送られることで知ることが出来ます。また、拡大操作が終了するタイミングは、scrollViewDidEndZooming:withView:atScale:がというデリゲートメソッドが送られることで取れます。

部分拡大機能

ビューア系のアプリでよくある、2本指のピンチイン、ピンチアウトで拡大・縮小する機能は前節のように比較的簡単に実装できるのですが、部分的に拡大したい、テンポ良くページを閲覧したい場合には使いづらく感じるときもあります。 そこで、画面をタップした後、そのまま長押ししたらタップした場所の周辺部が拡大された画面が表示され、タップした指を離すと消える、という動作にすれば、ページ遷移の動作の邪魔にならず、必要な部分だけを拡大表示出来るのではないかと、実装してみました。 今回、iOS3.2つまりiPadのリリースに合わせてサポートされたUIPopoverControllerを使って、部分拡大の表示を試してみます。 ポップオーバーとはどんなUI要素かというと、百聞は一見にしかず、下記のスクリーンショットをご覧ください。


このようにマンガの吹き出しのような画面を任意の場所に表示させることができます。指定した大きさのポップオーバーが画面に収まるよう表示位置を自動で調整してくれるのでお手軽です。 UIPopoverControllerを使うには、ポップオーバーの中身として表示するためのビューコントローラを用意する必要があります。 ビューア画面本体と同様、UIScrollViewのズーム機能を使います。 このビューコントローラはInterfaceBuilderを使うほど複雑な階層関係を持たないので、nibは作成せず、loadViewメソッドの呼び出しの中で、プログラマブルにビュー構造を生成します。 ビューの階層関係は、ビューア本体の表示画面同様、UIScrollViewの下位ビューにイメージを表示するビューを配置する形となります。




="prettyprint"> 1 - (void)loadView { 2 3 // ポップオーバー表示時に表示するサイズを指定 4 self.contentSizeForViewInPopover = CGSizeMake( kLupeViewWidth, kLupeViewHeight ); 5 // トップレベルのビューを作る 6 self.view = [[UIView alloc] initWithFrame:CGRectMake(0,0,kLupeViewWidth, kLupeViewHeight)]; 7 8 // スクロールビューを作る 9 lupeScrollView_ = [[UIScrollView alloc] initWithFrame:CGRectMake(0,0,kLupeViewWidth, kLupeViewHeight)]; 10 [self.view addSubview:lupeScrollView_]; 11 12 // イメージ表示ビューを作る 13 lupeImageView_ = [[UIImageView alloc] initWithFrame:contentFrame_]; 14 15 // イメージの縦横比率を保持して、長辺がビューに収まるよう表示する指定 16 lupeImageView_.contentMode=UIViewContentModeScaleAspectFit; 17 18 [lupeScrollView_ addSubview:lupeImageView_]; 19 lupeImageView_.backgroundColor = [UIColor blackColor]; 20 21 22 lupeScrollView_.delegate =self; 23 24 // ズーム可能にする(デフォルト値が1.0なので注意) 25 lupeScrollView_.maximumZoomScale = kLupeZoomRatio; 26 [lupeScrollView_ setZoomScale:kLupeZoomRatio animated:NO]; 27 28 }
上記コードで重要な点としては、このビューコントローラ自身のcontentSizeForViewInPopoverプロパティに適切な値を設定すること(4行目)、あらかじめ設定しておいたcontentFrame_をイメージビューの大きさとして設定する(13行目)でしょう。 実際にポップオーバーで表示させるには、ビューコントローラのviewDidLoadメソッドでUIPopoverControllerを作成し、長押しのハンドラで表示させます。

     1	- (void)viewDidLoad {
     2	  (略)
     3	  // 長押しrecognizerの登録
     4	  UILongPressGestureRecognizer* longTapRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongTapFrom:)];
     5	  [self.view addGestureRecognizer:longTapRecognizer];
     6	  [longTapRecognizer release];
     7
     8	  lupeViewController_ = [[LupeViewController alloc] initWithContentFrame:self.view.frame];
     9	  popover_ = [[UIPopoverController alloc]
    10		       initWithContentViewController:lupeViewController_];
    11
    12	}
UILongPressGestureRecognizerを生成してセットし、長押しが認識されたらhandleLongTapFrom:が呼び出されるように設定します(4-5行目)。次いで、今回作成したLupeViewController をinitWithContentFrame:で初期化することで、ズーム対象となるページ表示画面のサイズをLupeViewControllerに設定しておきます。 UIPopoverControllerにLupeViewControllerのインスタンスを指定して生成すれば準備完了です。

     1	- (void)handleLongTapFrom:(UIGestureRecognizer *)recognizer {
     2	  CGPoint location = [recognizer locationInView:self.view];
     3	  switch (recognizer.state) {
     4	  case UIGestureRecognizerStatePossible:
     5	    break;
     6	  case UIGestureRecognizerStateBegan:
     7	      lupeViewController_.image = [UIImage imageWithCGImage:  (CGImageRef)[[contentView_ layer] contents]];
     8	  case UIGestureRecognizerStateChanged:
     9	    {
    10	      [popover_.contentViewController zoomAt:location];
    11	      [popover_ presentPopoverFromRect:CGRectMake(location.x, location.y, 0,0)
    12			inView:self.view
    13			permittedArrowDirections:UIPopoverArrowDirectionAny  animated:YES];
    14
    15	    }
    16	    break;
    17	  case UIGestureRecognizerStateEnded:
    18	  case UIGestureRecognizerStateCancelled:
    19	    [popover_ dismissPopoverAnimated:YES];
    20	    break;
    21	  }
    22	}
長押しタップのハンドラも、基本的には通常タップのハンドラと同様です。長押しが開始されたタイミングでそのとき表示しているページのイメージをLupeViewControllerのインスタンスに渡し、UIPopoverControllerのpresentPopoverFromRectメソッドを呼び出せばポップオーバーが表示されます。presentPopoverFromRectにはタップされたビュー上の位置を渡します。permittedArrowDirections:にはUIPopoverArrowDirectionAnyを指定することで、ポップオーバーの吹き出しの部分が、ポップオーバーのウインドウ位置とタップされた座標の位置関係にあった形で表示されます。(下図参照)


まとめ

今回ご紹介したUIScrollViewは、UITableViewとならんでiPhoneのスムースな使い勝手に貢献している大きな要素ではないでしょうか。UIScrollViewがサポートする、ハードウェアによるスクロール/拡大機能を活用しない手はありません。本連載では今後も応用例をご紹介していくと思います。 今回のソースは、下記bzrコマンドで取得することができます。

bzr branch -r article-4-release lp:~klabrd/jisuireader/trunk article-4-release

次回予告

しおり機能など、これからやりたいことを実現しようとすると、書籍の内容に加えて、付加的なデータを保存する機能を追加する必要が出てきました。そこで、次回は、Core Dataを用いてデータの永続化を実装してみたいと思います。

前回までの目次





iPadで動く電子書籍アプリを作ってみる(3)

iPadで動く電子書籍アプリを作ってみる(3)

1 前回のフォロー

前回の記事でリリースしたコードに不具合を見つけました。Tipsでもあるので説明します。 初期画面から文書名をタップするとページのイメージが表示されますが、よく見るとナビゲーションバーの下辺にページの上辺がくっついて表示されています。本来は、ページの上辺はステータスバー(時計や電波状態が表示されている部分)の下辺から表示され、ナビゲーションバーと重なる部分はナビゲーションバーの下に透過して表示されるようになっていないといけません。 いろいろ調べたのですが、私がとった方法は、ナビゲーションバーのビュー階層の直下にスクロールビューを配置せず、間にUIViewを挟む方法です。



なぜこのようなコードが必要かを推測するに、どうやらNavigationControllerが初期画面を表示する際、初期画面がスクロール可能、すなわち、UIScrollViewを継承するビューの場合に、ビュー内部の表示内容がナビゲーションバーの領域に被さらないように、contentInsetをナビゲーションバーの高さだけ下に下げて表示させているようのですが、さらにpushViewControllerで次のViewを表示する際にも属性を引き継ぐかもしくは同じ調整を行っているようです。 これは、ちょっと余計なお世話な仕様のように思います。こういったことはフレームワークが暗黙的にやるより、フレームワークはデフォルトではcontentInsetを調整せずに表示して、UIScrollViewのドキュメントに、「ナビゲーションバーと併用するときは、.contentInsetプロパティで表示位置を調整してね」って書いてあった方がかえって混乱が少なくなる気がします。フレームワーク設計の常として、暗黙的にやってあげるべきことと、ユーザの判断でやってもらった方が良いことの線引きはなかなか難しいですね。

2 マウスイベントのハンドリング

さて、本題に入ります。前回でページイメージの表示を実現しましたが、まだ文書の1ページ目までしか表示出来無い状態でした。今回はタップイベントをハンドリングして、ページ送りを行えるようにします。 今回は、画面の左右両側1/3の領域をタップした場合に、ページを前後させ、中央部分をタップしたらナビゲーションバーの表示/非表示を切り替えるという仕様で考えます。



タップイベントのハンドリングにはiOS3.2、すなわちiPadリリースとともにサポートされたUIGestureRecognizerファミリのクラスを用います。 viewDidLoaddでUITapGestureRecognizerを作成して登録します。initWithTaget:action:の引数として、それぞれ、selfと、@selector(handleTapFrom:)を与えることで、タップ操作が起きたらPageViewController宛にhandleTapFrom:を送るよう指定します。

    1   UITapGestureRecognizer *recognizer;
    2   // UITapGestureRecognizerの作成と登録
    3   // recognizerが認識したイベントは自分宛をhandleTapFrom:送ってくるように設定
    4   recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapFrom:)];
    5   [self.view addGestureRecognizer:recognizer];
    6   [recognizer release];

ユーザが画面をタップすると送られるhandleTapFrom:を実装します。とりあえず今回は、左をタップすると次ページ、右をタップすると前ページを表示するように実装します。


    1 - (void)handleTapFrom:(UITapGestureRecognizer *)recognizer {
    2
    3   CGPoint location = [recognizer locationInView:nil]; // nilを指定するとスクリーン座標が取得できる
    4   LogPoint(location);
    5   UIWindow* window = [[UIApplication sharedApplication] keyWindow];
    6
    7   if( window.frame.size.width / 3 * 2 < location.x){ // 右
    8     Log("-->");
    9     if( document_.currentNombre > 1 ){
   10       document_.currentNombre--;
   11     }
   12     [self renderPage];
   13   }else if( location.x <= window.frame.size.width / 3 ){ // 左
   14     Log("<--");
   15     if( document_.currentNombre < [document_ pageCount] ){
   16       document_.currentNombre++;
   17     }
   18     [self renderPage];
   19   }
   20   else {// 真ん中
   21     Log(@"真ん中");
   22   }
   23
   24   [self renderPage];
   25
   26   [self.view setNeedsDisplay];
   27 }

見たまんまのコードですが、3行目、locationInView:でnilを渡すと、スクリーン座標系のタップ位置が取得できます。locationInView:はUIGestureRecognizerを設定したビューの階層関係にあるビューであればどのビューの座標系にも変換してくれます。余談ですが、Windowsのマッピングモードで苦労した記憶がある筆者にとってはとても便利なメソッドに思えます。 実際にタップを実行すると、なめらかにイメージが切り替わって表示されるのがわかると思います。これは、前回紹介したように、CALayerを用いてCGImageオブジェクトを設定すると、即座に画像が切り替わるのではなく、CoreAnimationの機能で自動的にアニメーションするようフレームワーク側が処理します。これは"Implict Animation"(暗黙的アニメーション)と定義されています。暗黙的とは逆に、明示的にアニメーションすることも出来ます。後の回で、Core Animationを用いてページめくりのバリエーションを試してみたいと思います。

3 ナビゲーションバーの表示・非表示

次に画面の真ん中をタップすると、ナビゲーションバーを非表示にすることで、ページ全体を読めるようにします。 ついでに、タイマーを用いて、文書を開いてから一定時間経過した場合もナビゲーションバーを非表示にするようにします。 まず、ナビゲーションバーを出したり消したりするメソッド、toggleNavbarです。 基本的にはナビゲーションバーの状態を参照して、現在の状態と逆の状態を設定するだけですが、表示させた場合にのみタイマーをセットして一定時間経過したら勝手に消えるようにします。


 01  -(void)toggleNavbar {
 02    if( self.navigationController.navigationBarHidden ){ // ナビゲーションバーが消えている
 03      [self.navigationController setNavigationBarHidden:NO animated:NO];// 表示する
 04      [self setNavbarTimer];	// 表示した場合は非表示タイマーを設定する
 05    }else {// ナビゲーションバーが出ている
 06      [self.navigationController setNavigationBarHidden:YES animated:NO];
 07      [self unsetNavbarTimer];
 08      // 非表示にした場合は消しっぱなし
 09    }
 10  }

-(void) setNavbarTimer {
  [self unsetNavbarTimer]; 
  navbarTimer_ = [[NSTimer timerWithTimeInterval:kEraseMenuInterval target:self selector:@selector(navbarTimerFired:)
				       userInfo:nil repeats:NO] retain];
    [[NSRunLoop currentRunLoop] addTimer:navbarTimer_ forMode:NSDefaultRunLoopMode];
} 
タイマーを生成して、retainして持っておきます。 タイマーが発火したら、navbarTimerFired:を送るように指定しておきます。

- (void)navbarTimerFired:(NSTimer*)timer {
  [self unsetNavbarTimer];
  Log(@"%s %@", __FUNCTION__, timer);
  [self toggleNavbar];

}
navbarTimerFired:はタイマーを無効化して、toggleNavbarを呼び出しているだけです。 タイマーの無効化メソッド、unsetNavbarTimerは下記のコードです。invalidateしてタイマーを無効化し、タイマーはこのコントローラがretainしていたのでreleaseすることでタイマーを解放します。

- (void) unsetNavbarTimer {
  if(navbarTimer_){
    [navbarTimer_ invalidate];
    [navbarTimer_ release];
    navbarTimer_ = nil;
  }
}
忘れがちですが、ビューそのものが非表示になるタイミングで送られるviewWillDisappear:メソッドの処理において、タイマーの解放のためにunsetNavbarTimerを呼び出すことと、反対に、ビューが表示されるタイミングで送られるviewWillAppear:中ではsetNavbarTimerを呼び出すようにします。 最後に、handleTapFrom:メソッドで画面中央部がタップされた場合のコードを修正し、

   20   else {// 真ん中
   21     [self toggleNavbar];
   22   }
として呼び出します。これで、ナビゲーションバーの表示/非表示をサポートできました。

4 まとめ

今回、基本的なタップ操作をサポートしたことでようやくビューアらしくなってきました。次回は画面の拡大・縮小について説明します。 今回までのコードは

bzr branch -r article-3-release lp:~klabrd/jisuireader/trunk article-3-release
で取得してください。

5 前回までの目次

iPadで動く電子書籍アプリを作ってみる(1) iPadで動く電子書籍アプリを作ってみる(2)

iPadで動く電子書籍アプリを作ってみる(2)

こんにちは、ponpoko1968です。人間でいうと20代後半に相当します。 少し間が開いてしまいましたが、第2回はページイメージの表示を行います。

1 ページ表示ビューの作成

まずページのイメージを表示するビューを作成します。(紛らわしいですが便宜上ページビューと呼ぶことにします。)Xcodeから、[ファイル][新規ファイル]を選択し、必要なオプションを指定(下図参照)して、「PageViewController」を作成します。
新規クラスの追加


PageVewController.hを開いて、下記のように定義を追加します。
@interface PageViewController : UIViewController <UIScrollViewDelegate>{
  IBOutlet UIView*      contentView_;
  Document*             document_;
}
@property (nonatomic,retain) Document*          document;
まず、UIScrollViewDelegateプロトコルを実装し、画面の拡大/スクロールに対応します。UIViewクラスのcontentViewメンバを追加します。IBoutletキーワードを前につけることで、Interface Builder(以下IB)から認識されるようにします。

1.1 nibの作成

次にPageViewController.xibをダブルクリックしてIBを立ち上げ、nibにUIScrollViewを追加します。下図のドキュメントウインドウの状態のように、スクロールビューのサブビューとしえViewが来るようにしてください。 (Viewをドラッグして、Scroll Viewの上でドロップすればOKです。)


nibとPageViewコントローラのメンバの結びつきを変えるため、IBのドキュメントウインドウでFile'sOwnerを選択し、インスペクタを表示します。


contentView_ → View view → Scroll View にそれぞれ結びつければ、IBを使っってのPageViewコントローラの準備は完了です。

2 ページビューの表示

2.1 Documentクラスの作成

ユーザが選択した文書をページビューが表示するため、文書前回Bookshelfクラスを定義し、アプリにインストールされた文書ファイル名を返すメソッドを定義しましたが、今回はBookshelfクラスにDocumentクラスの作成するメソッドを追加します。文書ファイルへのパス名を送ると対応するDocumentオブジェクトを返すメソッドを定義します。
-(Document*) documentWithPath:(NSString*)path {
  Document* doc = [documents_  objectForKey:path];
  if( doc ){
      return doc;
  }
  doc = [[Document alloc] initWithPath:path];
  [documents_  setObject:doc forKey:path];
  return doc;
}

2.2 画面遷移

文書一覧画面で、ファイル名をタップするとページビューに遷移して、選択したファイルを内容を表示するようにします。ファイル名がタップされたことをハンドリングするには、UITableViewDelegateプロトコルのtableView:didSelectRowAtIndexPath:をインプリメントします。
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
  // Bookshelfインスタンスを取得
  Bookshelf* shelf = [Bookshelf sharedBookshelf];

  // nibファイルの内容に基づいてページビューを作成
  PageViewController* pageVc = [[PageViewController alloc] initWithNibName:@"PageViewController" bundle:nil];

  // pageViewにDocumentオブジェクトをセットする(documentプロパティが暗黙的にretainする。)
  NSString* path = [[shelf documentNames] objectAtIndex:indexPath.row];
  pageVc.document = [shelf documentWithPath:path];

  // 画面遷移を行う
  [self.navigationController pushViewController:pageVc animated:YES];

  // ビューコントローラをnavigationControllerに渡したので所有権を放棄する
  [pageVc release];
}
この時点でiPhoneシミュレータで実行すると、左から右に画面が切り替わって、真っ白な画面が表示されると思います。 次はいよいよ、ページの表示を実装します。

3 ページクラスの定義

ここで文書に含まれるページを抽象化したPageクラスを導入します。 PageクラスはDocumentクラスに包含されるクラスなので、Documentが保持するページ数、すなわちページオブジェクトの数を返すメソッド、そして、ページ番号を指定してPageオブジェクトを取得するメソッドを定義します。

4 PDFイメージのレンダリング

PDFページのレンダリングは、Quartz(Core Graphics)に含まれるPDF関連のAPIを使います。APIを用いて画面に描画する流れとしては、
  1. PDFファイルをオープン
  2. Core GraphicsのPDFページオブジェクトを取得
  3. オフスクリーンバッファにPDFページオブジェクトを描画
  4. オフスクリーンイメージを画面に表示
という手順で行います。このあたりの処理は、Appleの公式ドキュメント「Quarts 2D Programming Guide」の「Opening and Viewing a PDF」節で解説されているので、併せて読んでみてください。 まず、PDFファイルを開きます。
 CFURLRef url = CFURLCreateWithFileSystemPath (NULL, (CFStringRef)self.document.filePath,
kCFURLPOSIXPathStyle, 0);
CGPDFDocumentRef pdfDocument = NULL;
pdfDocument = CGPDFDocumentCreateWithURL (url);
CFRelease(url);
所定のページを開きます。
 page =  CGPDFDocumentGetPage ( pdfDocument, self.nombre );
CGPDFPageGetBoxRect()でページの矩形を取得します。
 CGRect aRect = CGPDFPageGetBoxRect (page,kCGPDFCropBox ); 
このとき、第2引数で、kCGPDFCropBoxを指定すると、PDF形式に指定されたcropbox属性の値を返してくれます。PDFではページ全体の大きさとは別に、ページの周囲の余白を切り取った矩形をページのcorpbox属性として別途格納できます。ちなみに、pdf編集ソフトなどでは、自炊したPDFに後からcropbox属性を追加することが出来ます。余白を切り取ることで画面上の文字が大きく表示でき、見やすくなります。
 CGContextRef context = [self createBitmapContextWithSize:CGSizeMake(aRect.size.width*scale,aRect.size.height*scale)]; 
メモリ上のビットマップにQuartzのPDF描画関数を用いてレンダリングするため、ページサイズの情報を元に描画コンテキストを作るcreateBitmapContextWithSize:メソッドを呼び出します。(ソースコード参照)
 CGAffineTransform pdfTransform = CGPDFPageGetDrawingTransform(page, kCGPDFCropBox, aRect, 0, true);
CGContextConcatCTM( context, pdfTransform ); 
主にページの印刷方向を指定する目的で、各ページにはアフィン変換行列が指定されているので取得し、ページを描画する前に、設定します。 CGContextDrawPDFPage()で、描画コンテキストにレンダリングします。
 CGContextDrawPDFPage( context, page ); 
CGBitmapContextCreateImage()でCGImageオブジェクトに変換します。
 image = CGBitmapContextCreateImage ( context ); 
CGPDFDocumentは取得したページオブジェクトを内部でキャッシュするらしく1、iOSでオープンしたドキュメントはページ取得の都度オープン・クローズした方が良いようです。
 CGPDFDocumentRelease(pdfDocument); 

1 Life is Beutiful:iPadアプリ作成日誌: PDF関連APIのバグについてを参考にさせていただきました。大変助かりました。

5 PDFイメージの表示

イメージを画面に描画するにはいくつか方法がありますが、今回はCore Animationを用いた方法を使いました。 コードは下記のようにきわめてシンプルです。
-(void) renderPage {
Page* page = [document_ pageWithNombre:document_.currentNombre];
CGImageRef ref = [page imageWithScale:2.0];
[[contentView_ layer] setContentsGravity:kCAGravityResizeAspect];
[[contentView_ layer] setContents:(id)ref];
}

ビューからCore Animationのデフォルトレイヤーを取得し、レイヤーにsetContents:メソッドで、イメージを設定するだけです。 レイヤーのsetContentsGravity:メソッドでkCAGravityResizeAspectを指定することで、ページのアスペクト比を維持しつつビューいっぱいにページイメージが表示されます。下のスクリーンショットの例では、新書を自炊したものを表示しています。新書は縦に細長い判型のため、左右に生じた部分は黒く表示されています。これはページビューの背景色がそのまま出ています。開発に置いては、背景色を白以外の色にしておいた方がわかりやすいですが、リリース版では白にしておくとこのような縦長、もしくは横長の判型でも比較的違和感なく表示されると思います。


6 まとめ

今回はPDFファイルからページイメージをオフスクリーンバッファにレンダリングしたのち、画面へ表示するまでを説明しました。 PDFのページイメージの生成はメモリ、処理時間両方についてコストのかかる処理です。特に自炊の場合はページ全体がビットマップイメージとして保存されているのでなおさらです。この連載ではDocument,Pageオブジェクトと一緒にページイメージを永続化することで、処理の高速化を図る方法を解説する予定です。 次回はタップイベントを処理して、複数ページの表示を実現する方法を説明します。 今回より、launchpadというコードリポジトリサイトを用いてソースを公開します。
bzr branch -r article-2-release lp:~klabrd/jisuireader/trunk article-2-release
で取得してください。

7 前回までの目次

iPadで動く電子書籍アプリを作ってみる(1)

iPadで動く電子書籍アプリを作ってみる(1)

こんにちは、ponpoko1968です。 最近、「自炊」と言う言葉がはやっているのをご存じでしょうか?高性能で使いやすいドキュメントスキャナーが登場したことで、紙の本を断裁して、スキャンすることで電子化しPCやiPad,kindleのようなタブレットで読むことが流行しています。 私も、日々会社と家を往復する日々をすごすため、たくさんの重い技術書を持ち歩けず、何か疑問があって、調べたい、勉強したいというときに、すぐ手元に技術書があって、どこでも参照できたらなぁ。。。と常々思っていました。 そんな中、「自炊」とiPadの登場です。筆者も早速重い技術書をスキャンして、iPadで持ち歩くようになりました。おかげで過去の雑誌の記事や、解説書など、紙媒体の状態では重くて両手で持つことすら出来ない量の資料を1kgにも満たないiPadの中に入れて持ち運び出来るようになりました。 これら資料は、iPad向けに数多くリリースされている電子書籍リーダアプリを使って読んでいます。それぞれすばらしい機能があるのですが、なかなか筆者のニーズにはぴったり来るものがないなぁ、、、と思っていると、むくむくと「車輪の再発明」欲がわいてきてしまいました。そこで、せっかくだから(笑)電子書籍アプリの開発に挑戦してみることにしました。 今回の記事ではアプリのプロジェクトの構成の説明にはじまり、文書ファイルの一覧までを実装します。

2 コンセプト

私が考えた電子書籍リーダーのコンセプトを一言で言うと、「勉強やアウトプットが目的で読書する人のためのリーダー」ということになります。 というわけで自分で自分にRFPを出すと、こんな感じです。
  • 書籍データは(主に自炊した)PDFファイルをターゲット
    • PDFはリフロー型(*)の書籍形式ではないので、部分拡大機能を充実させる
  • 重要な部分などを簡単にクリップすることが出来、クリップした内容はローカルやクラウド上でコレクションできる
  • 地下鉄など電波が届かない場所で読むことも多いと思うので、機能実装においてはオフライン状態でつかうことも意識する
  • 描画・データ管理を工夫して高速・快適なブラウジングが出来るようにする
  • せっかく作るので、風呂敷を広げすぎない程度に、自分の想像力の範囲で、抽象化を意識したクラス設計にはする
当面の目標として、下記のような画面構成を考えます。

3 方針と設計

早速ですが設計に入ります。 今回のアプリはスタンドアロンのGUIアプリなのでシンプルなMVCに分解して考えます。 モデル系のクラスとしては、
  • BookShelfクラス 書籍データ全体を保持するSingletonなクラス。ディスクもしくはローカルRDBMSへのアクセスを抽象化する。永続化にはCoreDataを使ってみる予定
  • Documentクラス 個々の書籍データを保持するクラス。アプリで主に使う書籍データの実体はPDFファイルだが、目次やクリップ情報など、書籍単位で管理するデータの保持
  • Pageクラス ページの情報、ページイメージのキャッシュなど付加的なページのデータを保持。 という風に、 情報の粒度を基準にまとめることにします。

4 プロジェクトの作成

Xcodeでプロジェクトを作成します。[ファイル]→[新規プロジェクト]→[新規プロジェクトのテンプレートを選択:]で、「Navigation-Based Application」を選びます。実装に入る前に、忘れないように若干の設定変更を行います。

4.1 ファイル転送の設定

文書ファイルのアプリへの転送には、いろいろな方法が考えられますが、まずは今回はもっとも手っ取り早い、iTunesから転送する方法をサポートすることにします。 iTunesから文書ファイルをアプリへ転送できるようにするには、プロジェクトファイルの中の<アプリ名>-Info.plistに「Application supports iTunes file sharing(UIFileSharingEnabled)」と言うキーを追加して、その値を「YES」に設定します。 すると、iTunesにiPadを接続して、iPadを選択すると、[App]タブの画面下部にファイル転送用の画面が出現します。転送先のアプリのアイコンを選択して、PDFファイルをドラッグ&ドロップすれば即座にファイルが転送されるようになります。

5 Bookshelfクラスの実装

Bookshelfは転送された文書全体を管理するクラスです。シングルトンとして設計してアプリ全体からアクセスできるようにします。シングルトンなBookshelfのインスタンスはstatic変数としてクラスの実装内部で宣言します。
static Bookshelf* theBookshelf = nil;
常に単一のインスタンスを返す、sharedBookshelfというクラスメソッドを定義して、各Viewクラスはこのメソッドを起点に文書データにアクセスします。
  + (Bookshelf*) sharedBookshelf {
  if( nil == theBookshelf ){
    Log(@"%s:%d ", __FUNCTION__, __LINE__ );

    theBookshelf = [[Bookshelf alloc] init];
    UIApplication *app = [UIApplication sharedApplication];
    [[NSNotificationCenter defaultCenter] addObserver:
                                            [Bookshelf class]
                                          selector:
                                            @selector(sharedBookshelfWillClose:)
                                          name:
                                            UIApplicationWillTerminateNotification
                                          object:
                                            app];
  }
  return theBookshelf;
}
シングルトンの実装で課題となりがちなのが、シングルトンインスタンスの解放のタイミングですが、今回は、ノーティフィケーション機構を使って解放するようにしました。上記コードのように、アプリケーションが終了するタイミングで、[Bookshelf class]で取得されるクラスオブジェクトに、sharedBookshelfWillClose:メッセージを送ってもらうよう、ノーティフィケーションセンターに登録しておきます。呼び出されるsharedBookshelfWillCloseの中身は、現状はシンプルにインスタンスを解放するだけです。
+ (void) sharedBookshelfWillClose:(NSNotification*)notification {
 notification = notification;
 // シングルトンインスタンスの開放
 // UIApplicationのUIApplicationWillTerminateNotificationノーティフィケーションで実行
 Log(@"%s:%d ", __FUNCTION__, __LINE__ );
 if( theBookshelf)
   [theBookshelf release];
 }

6 文書の列挙

Bookshelfクラスはその名の通り文書を保持するクラスなので、下記のメソッドを定義します。
  • (NSInteger )documentsCount 文書数を返します。
  • (NSArray*) documentNames 文書の名前を格納したNSArrayを返します。
  • (Document*)documentWithName 文書の名前で指定されたDocumentsクラスのインスタンスを返す。 今回のアプリではアプリのバンドルのDocumentsディレクトリの直下にフラットに配置することにして、ファイル名を主キーにして、文書を識別することにします。アプリに転送された文書ファイルを列挙する処理については、サンプルコードのloadDocumentsメソッドを参照してください。

7 Documentsクラスの実装

DocumentクラスはおのおののPDFファイルへのアクセスを抽象化します。 実装と行っても、現段階のコードではファイル名を保持するだけのものなので、至ってシンプルに、2つのメソッドしか持ちません。
@interface Document : NSObject {
  NSString* filePath_;
}
-(id) initWithPath:(NSString*)path;
@property ( nonatomic, readonly, getter=getName ) NSString* name;

8 文書リストの表示

BookshelfViewControllerにコードを追加します。プロジェクト作成時点で、XCodeが生成する最初のビュー画面はすでにUITableViewになっているので、このビューをそのまま使います。
このビュー、プロジェクトの作成時のデフォルトはRootViewControllerという名前になっていますが、サンプルコードではBookshelfViewControllerという名前に変更しています。クラス名の途中変更はXCodeのリファクタリング機能を使うと便利です。
UITableViewで内容を表示するために必要な3つのメソッドの実装を書きます。 numberOfSectionsInTableViewはテーブルのセクション数を指定します。今回はテーブルをセクション分けて表示しないので、常に1を返します。
// Customize the number of sections in the table view.
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}
tableView:numberOfRowsInSection: でbookShelfが持つ現在の文書数を返します。
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
   Bookshelf* shelf=   [Bookshelf sharedBookshelf];
   Log(@"%s:%d %d", __FUNCTION__, __LINE__,[shelf documentsCount] );

   return [shelf documentsCount];
}
tableView:cellForRowAtIndexPath:でファイル名を表示するセルを作って返します。
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    static NSString *CellIdentifier = @"Cell";
   Bookshelf* shelf=   [Bookshelf sharedBookshelf];

    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
    }
    cell.textLabel.text = [[shelf documentNames] objectAtIndex:indexPath.row];

    return cell;
}
ちなみに、iPhoneシミュレータ上のアプリにPDFファイルを転送するには、下記のコードで取得されるパスに、直接ファイルをコピーすればOKです。 NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); NSString *aDirectory = [paths objectAtIndex:0];
これでようやく、文書のリストが画面表示されるようになりました。 次回はPDFページの表示を行います。 サンプルコードはこちら
 KLab若手エンジニアブログのフッター