Cocoa Break Logo Top Soft Develop  脱力空間 Logo
Apple Web Badge
made by mi

概要 翻訳 ソース リンク がらくた

概要 Examples ADC Samples 3rd Parties etc CBOriginals

CircleView

以下は、Mac OS X 10.4.11 上の Xcode 2.5 で説明しています。

目次:
1 README.rtf の日本語訳
2 アプリケーション
3 ファイル構成
4 MainMenu.nib
5 CircleView クラス
5.1 インターフェイス宣言
5.2 初期化と解放
5.3 描画
5.4 文字の自動回転
6 まとめ

1 README.rtf の日本語訳

CircleView は、グリフ自体の位置の計算ではなく、グリフ生成とレイアウトに対して Cocoa テキストシステムの下層レイヤーを使い、テキストを描画する NSView のサブクラスを実例で示すアプリケーションです。さらなる情報については、CircleView.m のコメントを見てください。

2 アプリケーション

ビルドして実行すると次のようなウインドウが現れます。

ウインドウの一番下のテキストフィールドに入力された文字が円形に表示されます。上のほうのスライダーを動かすと、円の直径が変化します。下のほうのスライダー動かすと、文字列が回転します。右側のカラーウェルで、文字の色を変えられるようになっています。その下の「Spin(回転させる)」ボタンを押すと、文字は自動的に回転しはじめます。

3 ファイル構成

CircleView.pbproj を開くと、次のようになっています。

新しく定義されているクラスは CircleView.h と .m だけです。Resources にある MainMenu.nib にユーザーインターフェイスの定義があります。

4 MainMenu.nib

MainMenu.nibは、次のようになっています。

ここでメインは、独自のビューの CircleView です。他のコントロールから、アクションがビューに接続されています。よくあるパターンとは違って、コントローラークラスにあたるものが作られていないことに注意してください。File's Owner の delegate(委任)も接続されていません。基本的にアクションによって駆動されるようになっています。

上のスライダーからは takeRadiusFrom:、下のスライダーからは takeStartingAngleFrom:、いちばん下のテキストフィールドからは takeStringFrom:、右のカラーウェルからは takeColorFrom:、右の Spin ボタンからは toggleAnimation: に、すべて CircleView をターゲットとして接続されています。

5 CircleView クラス

5.1 インターフェイス宣言

CircleView.h でインターフェイス宣言が行われています。このクラスではたくさんのメソッドが宣言されていますが、それについては個々に見ていくことにして、インスタンス変数などのデータ構造を見てみます。

CircleView.h > 継承とインスタンス変数
@interface CircleView : NSView { NSPoint center; // 円の中心 float radius; // 円の半径 float startingAngle; // 開始場所の角度 float angularVelocity; // 自動回転する時の角速度 NSTextStorage *textStorage; // テキストデータを格納する補助記憶 NSLayoutManager *layoutManager; // レイアウトを行うテキストシステムオブジェクト NSTextContainer *textContainer; // レイアウトの対象領域を扱うテキストシステムオブジェクト NSTimer *timer; // 自動回転の時に使うタイマー NSTimeInterval lastTime; // タイマー利用時に使う時間間隔 }

ソースにつけたコメントで十分わかると思います。わからなければ、個々のメソッドでどう使われているかを見てください。NSTextStorageNSTextContainer は、どちらも格納的な言葉なので誤解しやすいですが、NSTextStorage は、NSMutableAttributedString(変更可能な属性付き文字列)のサブクラスで、純粋にテキストデータを保存するために使われます。NSTextContainer は、レイアウトが行われて表示される場所の情報を扱います。これを実際に表示するにはビューが必要です。テキストコンテナは、レイアウト計算を行うために、サイズなどの情報を保持しています。この 2 つが別々になっていることで、実際に表示しなくても、レイアウトした状態での行数や他の状態をチェックできます。また複数のテキストコンテナを入れ替えれば、同じビューの表示を切り替えることも簡単です。レイアウトマネージャーは、テキストシステムに最初から組み込まれたもので、文字のレイアウトを計算します。タイマー関係は「Spin」ボタンのアクションメソッドで説明します。

この後で、メソッド宣言が行われていますが、初期化と解放、描画、イベント処理、設定メソッド、アクションメソッド、タイマーから呼ばれるメソッドという順番で並んでいます。

5.2 初期化と解放

このサンプルでは、アプリケーションの委任となるようなコントローラークラスがありません。中心的な役割は CircleView クラスがにないます。サンプルが起動され、nib ファイルが読み込まれると、ウインドウに置かれた CircleView が独自ビュー (CustomView) なので、その initWithFrame: メソッドが呼び出されることになります。

CircleView.m > initWithFrame:
- (id)initWithFrame:(NSRect)frame { [super initWithFrame:frame]; // 最初にさまざまな変数のデフォルト値を設定 center.x = frame.size.width / 2; // 円の中心をフレーム中央に center.y = frame.size.height / 2; radius = 115.0; startingAngle = M_PI_2; angularVelocity = M_PI_2; // 次に、テキストシステムの3つの基本的なビューでない構成要素 // NSTextStorage、NSLayoutManager、NSTextContainer // のインスタンスを作成して初期化する textStorage = [[NSTextStorage alloc] initWithString:@"Here's to the crazy ones, the misfits, the rebels, the troublemakers, the round pegs in the square holes, the ones who see things differently."]; layoutManager = [[NSLayoutManager alloc] init]; textContainer = [[NSTextContainer alloc] init]; [layoutManager addTextContainer:textContainer]; [textContainer release]; // layoutManager は textContainer を保持することになる [textStorage addLayoutManager:layoutManager]; [layoutManager release]; // textStorage は layoutManager を保持することになる // スクリーンフォントは拡大縮小または回転描画に適用でない // テキスト描画に対して NSLayoutManager を直接使うビューは // この変数を適切に設定したほうがいい [layoutManager setUsesScreenFonts:NO]; return self; }

最初にインスタンス変数の値を設定しています。M_PI_2 は math.h で定義されていて、π の半分です。これは 90 度と同じことになります。

それからテキストシステム関連の初期化を行っています。まずテキスト格納を作って、それに決まった文字列を入れています。そして、レイアウトマネージャーとテキストコンテナを作り、レイアウトマネージャーにレイアウトを行う対象としてテキストコンテナを追加します。レイアウトマネージャーで保持されるので、解放しています。また、テキスト格納にレイアウトマネージャーを追加して解放しています。これで、この 3 つのオブジェクトで、このインスタンスが保持 (retain) しているのは、テキスト格納だけということになります。他のインスタンス変数には nil が入れられていません。これらは他のメソッド内で参照するときに利用できるよう、そのままにされています。最後にスクリーンフォントを使わないように設定しています。

これに対応して dealloc メソッドは、以下のようになっています。

CircleView.m > dealloc
- (void)dealloc { [timer invalidate]; [timer release]; [textStorage release]; [super dealloc]; }

まず、タイマーが動作していたら、それを無効化して解放しています。これについては後の自動回転の説明を見てください。次にテキスト格納を解放しています。これにより、レイアウトマネージャーが解放され、したがってテキストコンテナも解放されることになります。他のインスタンス変数はオブジェクトではなくスカラー値なので、あとはスーパークラスの実装を呼んでいるだけです。

5.3 描画

initWithFrame: が呼ばれて初期化されるのは、nib から読み込まれてインスタンスが作られる時です。初期化が成功すれば、他のインスタンスとともに指定どおりビュー内に配置され、MainMenu.nib で、ウインドウの「visible at launch time(起動時に表示)」がチェックされていたので、ウインドウが表示されることになります。この時に、ウインドウ内に配置されたそれぞれの項目に描画を行うよう指示が送られます。CircleView 以外は、AppKit に組み込まれたものなので、各自勝手に描画を行います。CircleView は独自ビューなので、drawRect: を実装して描画を行わなければなりません。

CircleView.m > drawRect:
- (void)drawRect:(NSRect)rect { unsigned glyphIndex; NSRange glyphRange; NSRect usedRect; [[NSColor whiteColor] set]; // 白色に設定 NSRectFill([self bounds]); // ビュー全体を白で塗りつぶす // usedRectForTextContainer: はレイアウトを強制せず、 // そのためレイアウトを強制する glyphRangeForTextContainer: // の後に呼ばれなければならないことに注意してください glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; 1 usedRect = [layoutManager usedRectForTextContainer:textContainer]; for (glyphIndex = glyphRange.location; glyphIndex < NSMaxRange(glyphRange); glyphIndex++) { 2 NSGraphicsContext *context = [NSGraphicsContext currentContext]; NSRect lineFragmentRect = [layoutManager lineFragmentRectForGlyphAtIndex: glyphIndex effectiveRange:NULL]; NSPoint viewLocation, layoutLocation = [layoutManager locationForGlyphAtIndex:glyphIndex]; float angle, distance; NSAffineTransform *transform = [NSAffineTransform transform]; // ここで layoutLocation は、(コンテナ座標で)グリフが配置される位置 layoutLocation.x += lineFragmentRect.origin.x; 3 layoutLocation.y += lineFragmentRect.origin.y; // それから円のまわりのグリフの適切な位置 //(角度と距離、または長方形座標内の viewLocation) // を計算するためlayoutLocation を使う distance = radius + usedRect.size.height - layoutLocation.y; 4 angle = startingAngle + layoutLocation.x / distance; viewLocation.x = center.x + distance * sin(angle); 5 viewLocation.y = center.y + distance * cos(angle); // 円のまわりの計算された位置にもとづいて配置し回転するため // 各グリフそれぞれに対して異なるアフィン変換を使う [transform translateXBy:viewLocation.x yBy:viewLocation.y]; 6 [transform rotateByRadians:-angle]; // 変換がそのグリフだけに適用されるように、グラフィック状態を保存して復元 [context saveGraphicsState];7 [transform concat]; // drawGlyphsForGlyphRange: はコンテナ座標のレイアウト位置にグリフを描画 // グリフを配置するために変換を使っているので、レイアウト位置はここでは引かれない [layoutManager drawGlyphsForGlyphRange:NSMakeRange(glyphIndex, 1) atPoint:NSMakePoint(-layoutLocation.x, -layoutLocation.y)]; [context restoreGraphicsState]; } }

少々長いですが、行われていることは順を追っていけば理解できるでしょう。まず最初に背景を白で塗りつぶしています。

それから 1 で、レイアウトマネージャーにグリフ生成を行わせています。このメソッドは、テキストコンテナ内にグリフをレイアウトし、その範囲を返すものですが、まずグリフ生成を行い、必要ならレイアウトします。これは、テキスト格納オブジェクトのなかの文字のグリフのレイアウトされた範囲を返します。

このサンプルでは、テキストコンテナを initWithContainerSize: ではなく、単に init を使って初期化していました。つまり、内容サイズは設定してなかったわけです。サイズもないのにそこにグリフを配置できるのか、と思いますが、初期化メソッドに NSLog(NSStringFromSize([textContainer containerSize])); という文を挿入し、実行すると、{1e+07, 1e+07} と出力されます。これは 10,000,000.0 × 10,000,000.0 となり、かなり大きいサイズです。サンプルがわかりにくい時は、NSLog 関数などを利用するか、デバッガを使って値を追いかけていくのが有効です。このように、サイズを指定しなければ非常に大きいサイズで初期化されていることがわかります。なので、文字のすべてが配置されグリフ生成されることになります。それなら、返された範囲は全文字だから使う必要がない、と思うかもしれませんが、1 文字 = 1 グリフとはかぎらないため、返されたグリフ範囲を使って作業をしなければなりません。デバッガ上で確認すると、この範囲は location = 0, length = 142 となります。

次の行で、レイアウトマネージャーにテキストコンテナに配置された実際のグリフ範囲を問い合わせています。これはテキストコンテナ内に「描かれた」グリフの範囲です。これで実際にグリフ生成された時の高さや幅などがわかります。デバッガで確認すると、usedRect が、原点 (0 0)、幅 746.932983、高さ 14 の長方形であることがわかります。テキストシステムにおいて座標はすべて左上から計算されることに注意してください。これはテキストコンテナ内の左上隅にある長方形です。

そして、2 で、グリフ生成されたグリフそれぞれに繰り返しを行い、グリフを1つずつ描画していく作業に入ります。for 文は、単純にグリフ範囲の最初から最後まで1つずつ繰り返すものです。glyphIndex が現在作業中のグリフになります。最初の回はこの値は 0 です。

まず、現在のグラフィックコンテキストを取得します。次に、現在作業中のグリフに対する行断片長方形を取得しています。これは、この文字が含まれている行に対する長方形を返すもので、原点 (0, 0)、幅 10,000,000、高さ 14 となります。じつは 2 回目のこの値は同じです。なので、このサンプルにおいては、この取得は必要なく計算に加味する必要もないと思われます。テキストコンテナに特定サイズが設定されていて、2 行にわたる可能性がある場合、これを計算に含める必要があります。幅は上で調べたテキストコンテナの高さと同じで、高さが usedRect と同じであることに注意してください。次に、locationForGlyphAtIndex: でテキストコンテナ座標におけるグリフ位置をlayoutLocation に取得しています。これは初回は (5, 11)、2 回目では (約13.7, 11) のように少しずつ右側へ動いてることがわかります。このグリフ位置は行断片長方形の左上隅を原点として描かれているグリフのベースラインの左端を表すようになっています。ベースラインなので、下に出る文字などでは文字の中間にこの位置がくることになります。後で変換を行うための空のアフィン変換を準備しています。

3 からグリフの位置の計算に入ります。まず、layoutLocation に行断片長方形の原点が加算されています。しかし、このサンプルでは行断片長方形の原点はつねに (0, 0) なので、layoutLocation は同じままです。

そして、4 で、グリフを描く円のサイズを計算しています。まず、グリフの中心からの距離ですが、設定された半径に描かれたグリフの高さを足して、そこからベースラインまでの距離を引いています。これによって、ビューに描かれた時の中心から文字のベースラインまでの距離が計算できたことになります。次に角度の計算です。startingAngle は初期化の時に 90 度に設定されていました。そこから、どれだけ離れているかを計算します。ここで単位がラジアンであることに注意してください。ラジアンのため、layoutLocation.x / distance で角度計算は OK です。1 文字動くたびに、layoutLocation.x は大きくなるので、角度も大きくなります。

5 では、ビュー上の表示位置を求めています。中心の座標に角度から計算された位置を足しています。

6 では、変換を作っています。まず、直前で求めた表示位置へと平行移動しています。そして、計算された角度だけ回転させます。

7 以降で、実際にグリフを描画します。まずグラフィック状態を保存しています。これは変換を行うので、それが累積されないようにするためです。付加的に変換を行うことも考えられますが、変換の数が多くなるのでヤメたほうがいいでしょう。1 回ごとに変換を作ってやるほうが無難です。保存した後で、変換を適用します。次にレイアウトマネージャーにグリフを描画させます。1文字だけ指定しています。atPoint: の位置は、テキストコンテナの位置をビュー座標で表したものです。初回だと (-5, -11) となります。グリフ位置だけマイナスすることで、テキストコンテナの位置は毎回ずれていき、したがって毎回原点にグリフが描画されるので、あとは変換でレイアウト位置まで運ばれることになります。[transform concat]; を // を使ってコメントアウトして実行してみましょう。グリフがビューの左下隅に重なって描画されているのがわかると思います。このように、サンプルの計算の一部をコメントアウトして実行されないようにして、どう変化するかを調べるのも有効です。

// [transform concat]; として実行した場合

これで、描画が理解できたでしょう。理解しにくい方は、デバッガ等で1回ずつ値がどう動くのかを見てみればいいと思います。

あと描画で注意するのは、isOpaqueYES を返すよう実装されていることです。このビューは半透明ではないので、このメソッドを実装しています。半透明でないビューを作るときは、このメソッドの実装を忘れないようにしたほうがいいでしょう。

5.4 文字の自動回転

右下の「Spin」ボタンをクリックすると、文字が自動回転を始めます。このボタンは toggleAnimation: というアクションメソッドに接続されています。これを見てみましょう。

CircleView.m > toggleAnimation:
- (IBAction)toggleAnimation:(id)sender { if (timer != nil) { [self stopAnimation:sender]; } else { [self startAnimation:sender]; } }

このメソッドは、単に別のメソッドを条件によって呼び出しているだけです。インスタンス変数の timernil でなければアニメーションを停止し、nil だったら開始します。まず開始するほうの startAnimation: を見てみます。

CircleView.m > startAnimation:
- (IBAction)startAnimation:(id)sender { [self stopAnimation:sender]; // タイマーを 30 fps アニメーションレートを望んで予定に入れる // performAnimation: において、正確にどれほど時間がたったかを調べ、 // それに従ってアニメーションを行う timer = [[NSTimer scheduledTimerWithTimeInterval:(1.0/30.0) target:self selector:@selector(performAnimation:) userInfo:nil repeats:YES] retain]; // 次の 2 行はアニメーションがモーダルパネルが表示されている間 // イベント追跡(たとえばスライダーがドラッグされている前) // が行われている間も起こり続けることを確実にする [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSModalPanelRunLoopMode]; [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSEventTrackingRunLoopMode]; lastTime = [NSDate timeIntervalSinceReferenceDate]; }

まずタイマーを作成して、それを実行ループに入れています。1 秒間に 30 回繰り返し performAnimation: を呼ぶように設定しています。ここで設定した時間間隔はあくまで望ましいものなので、実際に正確に呼ばれるとはかぎりません。正確に呼ばれるようにするには、もっと時間幅を短くして、別メソッドを経由させ、そこで経過時間をチェックして、1/30 秒たった時だけ、performAnimation: を呼ぶ方法もあります。またもっと低水準のシステムも利用できます。このサンプルの場合、呼ばれる時間の正確さは関係ないので気にすることはありません。呼ばれたメソッド内で時間経過を調べて、それにしたがって角度調節を行っているだけです。

次の2行はコメントどおり、タイマーをデフォルトの実行モード以外にも入れているだけです。これによって、追加された 2 つのモードで実行ループが行われている時でも、毎回タイマーがチェックされることになります。このように、実行ループモードは、それぞれ、そのモードで実行している時にチェックすべき項目を別々にもっています。最後に現在の絶対時刻を保存しています。

次に毎回呼ばれるメソッドを見てみます。

CircleView.m > performAnimation:
- (void)performAnimation:(NSTimer *)aTimer { // 最後のアニメーションからどのぐらい時間がたったかを判定し、 //それにしたがって角度を進める NSTimeInterval thisTime = [NSDate timeIntervalSinceReferenceDate]; [self setStartingAngle:startingAngle + angularVelocity * (thisTime - lastTime)]; lastTime = thisTime; }

まず現在の絶対時刻を取得して、それと保存してある前回時刻との差を求め、それに角速度を掛けて、その結果を描画の開始角に加算しています。これによって開始角か少しずつ進んでいくことになります。最後に、今回の時刻を保存しています。アニメーションとはいうものの、描画はここでは行われません。モデルの数値を動かしているだけです。つぎはアニメーション終了を見てみます。

CircleView.m > stopAnimation:
- (IBAction)stopAnimation:(id)sender { [timer invalidate]; [timer release]; timer = nil; }

タイマーを無効化し、startAnimation: 内で保持していたので解放しています。それからタイマーに nil を入れています。nil を代入しているのは、切り替えメソッドでタイマーの有無で開始か終了かを決めていたので重要です。また、dealloc 内で、アニメーションを動かしたまま終了した場合の対策として解放を行っていましたが、その場合も nil が代入されていることが必要となります。

6 まとめ

他にも、ユーザーインターフェイスから呼ばれるアクションメソッドなどがありますが、設定を変えるだけの簡単なものです。このサンプルは単純ですが、テキストシステムを直接操作するため、初心者向けではありません。なので、他の部分の細かい説明はカットします。

このサンプルより、もっと単純な例が『Cocoa のためのテキストレイアウトプログラミングガイド』の「任意のパスに沿ったテキストのレイアウト」に示されています。このサンプルは、その説明を補強し、1 文字ずつの位置計算のやり方などを示したものになっています。


管理人:神吉 秀典 E-mail:puer@ba.wakwak.com