jarinosuke blog

about software engineering, mostly about iOS

実践 Auto Layout

今こそ frame 思考脱却の時

Xcode 4 / iOS 6 から存在していた Auto Layout でしたが、

当時は Interface Builder の Auto Layout 対応も中々ひどく、使うのが辛かった記憶があります。

そんななか僕は順調に layoutSubviews に傾倒していったわけですが、

iPhone 6/iPhone 6 plus がついに登場し、Size Class という新しい概念も投入され

現状では間違いなく2年前とは比べ物にならないレベルで Universal アプリは作りやすくなりました。(ただし iOS 8 専用アプリのみ)

ある程度のデザインパターンを懐に用意していた方が時間が省けます。

ここでは Auto Layout を用いたレイアウトに関するユースケース毎に簡潔に書いていますので、

「それ知ってるわ」みたいなのがあったら適宜読み飛ばしていって下さい。

また Auto Layout の基礎の基礎の部分は省いています。

等幅間隔で設置する

Auto Layout に少しずつ慣れてきて、今まで layoutSubviews でやってきたことをやろうとして

一番最初に「どうやるのこれ」ってなるのがこれかなと思います。

以下のリンクで僕も勉強させてもらいました。

Auto Layout は2つの View 間の制約しか設定出来ないため、等間隔を実現するとなると

間隔毎に見えない Spacer View を置いて、それらの幅を同じにする制約を張ることになります。

今までのレイアウト思考に慣れていると「えー、めちゃくちゃ余分な View 増えるな…」となりますが、それしか無いんです。しょうがない

中心から一定間隔ずらす

起動時のスプラッシュ画面も iOS 8 から nib で作成出来る様になり、

中心から50pt上にロゴを配置したいなどといった要件がある場合もあると思います。

こういった場合は Align Center Y を張った後に、その Constraint の constant を変更する事で可能です。

失敗例として自分がやっていたのは、ロゴを透明な Container View の中に置き

その Container View を Align Center Y して、その中でロゴを pin したりしていました。

UILabel を text の長さによって複数行にする

UILabel に入るテキストの長さによって、UILabel の大きさを変えたい事は多々あります。

手順を追いながら一つずつ簡単に説明します。

  • 1. numberOfLines プロパティを0に設定

numberOfLines に 0 を設定すると行数が無制限になります

  • 2. 位置を指定する Pin Constraints を設定

UILabel の size を決定する前に origin を決定します

  • 3. 高さを指定する Height Constraint を Low Prority(=250) で設定

可変の高さにしますが、height の制約は必要になります。 Priority を Low に設定する事で後述する設定が活きます。

  • 4. ContentCompressionResistancePriority(=251) より Height Constraint が低くなっている事を目視確認

Xcode 6 で確認した限りは UILabel の ContentCompressionResistancePriority はデフォルトで251となっていたので、 Low Priority の 250 よりは高くなっていると思いますが、目視で念のため確認します。

  • 5. ContentHuggingPriority(=750)の方が Height Constraint より高くなっている事を目し確認

4と同様、デフォルトで ContentHuggingPriority は750になっていると思いますが目視で確認します。

  • 6. preferredMaxLayoutWidth を設定

これを設定する事で UILabel が text から frame を計算する時の width を決定する足がかりとなります。

Xcode 6 から Automatic Preferred Max Layout Width なるものが Interface Builder に登場していますが、

残念ながら以下のリンクの通り iOS 8 以上サポートなので条件に満たさない場合はしっかり設定します。

Automatic Preferred Max Layout Width is not available on iOS version prior to 8.0

上記では簡単に説明しましたが、コードもしくは Interface Builder を用いた具体的な手順は以下のリンクが大変分かりやすいです。オススメです

UILabel sizeToFit doesn’t work with Auto Layout iOS 6

また上記で出てきた content compression resistence priority や content hugging priority、それに付随する intrinsicContentSize の説明は綺麗に省きましたが、以下のリンクが丁寧に解説されていて分かりやすいので参照下さい。

[iOS 7] Xcode 5 で始める Auto Layout 入門 #6 -補足編

UIScrollView の subview と Auto Layout と contentSize

Auto Layout 使用時には contentSize をコードなどで直接セットしてはいけません。

viewDidLayoutSubviews のタイミングとかでできてしまうと思うんですが不適切です。

Auto Layout 使用時には内部の subviews の制約により contentSize が決定されるべきです。

以下のリンクの文章を参考にすると contentSize を決定する際に、

UIScrollView And Autolayout | Technical Note TN2154

contentView の Constraints は自身のサイズ(contentSize)を UIScrollView の size とは独立して計算出来る様になっていなければいけない

と解釈しました。

何が言いたかったというと UIScrollView 内の Auto Layout は結構ややこしいため、よっぽど静的で単純でない限りは未だにコードで実装しています。

以下のスライドでも UIScrollView + Auto Layout について取り上げられていますが、完全に同意です。

黒魔術AutoLayoutとiPhone 6/6 plus

Auto Layout まめ知識

Interface Builder で行う Constraint 設置作業はスポーツに近いものがあります。

どんどん出てくる warning をモノともせず、無心で Constraint をはっていくのみです。

そんなときに以下のショートカットを知っておくと少しだけクリックの負担が減ります。

  • Update Frames shift + cmd + =

  • Update Constraints opt + cmd + =

欲を言うと、 Clear Constraints も欲しかった…

終わりに

まだまだ他にも Interface Builder 上では warning 出なくなったのに

Debugger Console でひたすら Unsatisfied Constraints が出てきたりする対策方法などの

デバッギングについて紹介したかったんですが、長くなってしまったので一旦これで。

他にも何かユースケースなどあったら追加しますので教えて下さい!

参考資料

iOS 8 で設定アプリ内にあるアプリの設定へ遷移させる

設定アプリへの遷移

ローカル通知や位置情報の Always と WhenInUse の二種類の認証、 HealthKit など、

iOS 8 では iOS 7 よりも多くのユーザの許可が必要な情報が増えたように思います。

しかし、ユーザに初回のアラートで拒否されてしまうと iOS 7 まででは

アプリ自身が設定画面へ遷移させて再度許可してもらう方法はありませんでした。

設定アプリへ非公開のカスタムスキームを用いて遷移させるという手法もありましたが。

iOS 8 での設定画面への遷移方法

iOS 8 ではアプリが何かしらのカスタマイズ設定を保持している場合、

設定アプリ内にアプリ毎の設定ルートビューができました。

そのビューに以下のメソッドを実行する事で遷移する事が出来ます。

[[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];

これは iOS 8 から追加された UIApplicationOpenSettingsURLString という定数を用いています。

上記の定数の中身をランタイムでのぞいてみると app-settings: とだけ書いてあるので、

特にアプリに独自のカスタムスキームを設定する必要は無さそうです。

デフォルト引数を使って簡易メソッドを簡単に作る

複数引数を扱うメソッドの簡易メソッドObjective-Cで用意するには

初めに Objective-C での事例を説明してから、Swift の話しに移った方が分かりやすいと思います。

Objective-C では以下の様にして、簡易メソッドの数だけメソッドを生やす必要がありました。

- (void)function
{
    [self function:nil param2:nil];
}

- (void)function:(id)param1
{
    [self function:param1 param2:nil];
}


- (void)function:(id)param1 param2:(id)param2
{
    //肝心の処理
}

書くのも少し大変ですが、何よりコードを読む時に場合が大変です。

場合によっては、何度もジャンプをして肝心の処理が記述されているメソッド

ようやくたどり着くなんてこともあります。

Swift におけるオプション引数

Swift では引数にデフォルト値を指定する事が出来ます。

デフォルト値を設定すると、その引数無しでその関数を実行してもエラーが起きないので

それだけで簡易メソッドとして扱う事が出来る様になりました。

以下を Playground などで実行してみると分かると思います。

func function(param1 : AnyObject? = nil, param2 : AnyObject?  = nil) {
    if param1 != nil {
        println("param1")
    }
    
    if param2 != nil {
        println("param2")
    }
}

function()
function(param1: 1)
function(param1: 1, param2: 2)

UIScrollViewKeyboardDismissMode について

出たままのキーボードをスクロールされたら良い感じに閉じる

iOS 純正アプリの Messages みたいなキーボードの挙動をやりたいなー、と調べていました。

どういう挙動かというと、

  • キーボードが出ている状態でスクロールダウンすると、それに伴ってキーボードも下がる
  • ドラッグしながら下げるのをやめて上にあげるとキーボードも戻ってくる

と、大変インタラクティブな挙動になっているのです。

まぁここまでやらなくとも、せめてスクロールビューへのドラッグなどのジェスチャーを検知して

キーボードを dismiss したいと思いました。

iOS 7 以前

iOS 7 より前では、簡単に実装するとすればスクロールビューに以下のようなことを

していてフックしていたかなと思い出しました。

  • UIGestureRecognizer をスクロールビューに付与してイベントを取得
  • UIScrollViewDelegate を受け取り、スクロールイベントを取得

上記のイベントをフックして、

[textField resignFirstResponder];

を実行していたかと思います。

これでも良いんですが、例えばiOS 純正アプリの Messagesインタラクティブ

なことをやろうとすると非常に手間で独自実装が増えてしまいます。

iOS 7

ここからようやく本題です。

タイトルにもある UIScrollViewKeyboardDismissMode という

APIiOS 7 から追加されています。

UIScrollView に keyboardDismissMode としてプロパティがあります。

デフォルトでは UIScrollViewKeyboardDismissModeNone が設定されているため、

  • インタラクティブしたいなら UIScrollViewKeyboardDismissModeInteractive
  • 単純にドラッグされた時にキーボードを消すだけなら UIScrollViewKeyboardDismissModeOnDrag

を設定するだけです。

iOS 7 からは、これだけでスクロール時のキーボード表示/非表示を行う事ができます。

参考

Text Programming Guide for iOS - Managing the Keyboard

Dismissing the keyboard in a UIScrollView

UIView の Animation Block の中で行われている事

Animation Block

iOS 4 から UIView のアニメーションを簡単にするために、以下のアニメーションに関するクラスメソッドが UIView に追加されました。

iOS 開発に携わってる人ならみんな知ってると思います。

+ (void)animateWithDuration:(NSTimeInterval)duration animations:(void (^)(void))animations;

+ (void)animateWithDuration:(NSTimeInterval)duration animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion;

+ (void)animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion;

上記以外にも iOS 7 からは以下のようなキーフレームや UI Dynamics を用いたアニメーションのためのメソッドも追加されています。

+ (void)addKeyframeWithRelativeStartTime:(double)frameStartTime relativeDuration:(double)frameDuration animations:(void (^)(void))animations;

+ (void)animateKeyframesWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay options:(UIViewKeyframeAnimationOptions)options animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion

+ (void)animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay usingSpringWithDamping:(CGFloat)dampingRatio initialSpringVelocity:(CGFloat)velocity options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion

これらのメソッドのおかげで、とても簡潔に UIView をアニメーションさせることができるようになりました。

しかし、実際にあの Block の中ではどのような処理が走ってアニメーションが実行されているかが上手に隠蔽され、

あれだけで済んでしまったことによる Core Animation に対する認識不足が自分の中で目立ってきたので今回は調べてみました。

CAAnimation による明示的アニメーション

上記の UIView の簡易 API ができる以前はどのようにアニメーションを実装していたのか、

というそもそもの所に戻ってみるのが近道っぽいです。

以下のコードは Core Animation Programming Guide - Animating Layer Content の章にあるサンプルです。

CABasicAnimation* fadeAnim = [CABasicAnimation animationWithKeyPath:@"opacity"];

fadeAnim.fromValue = [NSNumber numberWithFloat:1.0];

fadeAnim.toValue = [NSNumber numberWithFloat:0.0];

fadeAnim.duration = 1.0;

[theLayer addAnimation:fadeAnim forKey:@"opacity"];

 
// Change the actual data value in the layer to the final value.

theLayer.opacity = 0.0;

CAAnimation を作成し、それを CALayeraddAnimation:forKey: することでアニメーションを実行しています。

Animatable Property

少し脇道にそれますが、CALayer には animatable property というのがあります。以下をみるとわかりますが、ほとんどがそれです。

Core Animation Programming Guide - Animatable Properties

では animatable property とは何なのでしょうか?

animatable property とはその名の通りアニメーション可能な値のことで、

その値を変更すると actionForKey: が発火して、然るべき CAAction プロトコルに準拠したオブジェクトを返し、暗黙的アニメーションを実行するというプロパティです。

actionForKey: が CAAction プロトコルに準拠したオブジェクトを返す流れは reference を見るのがわかりやすいです。

特別な定義が無ければ、animatable property と一緒に定義された CAAnimation が返ります。

ということで、CAAnimation による明示的アニメーションと animatable property が行う暗黙的アニメーション

について分かったところで UIView の話に戻ります。

結論

上記でも書きましたが、スタンドアローンCALayer の animatable property が変更された場合は、CAAnimation が発行されて暗黙的アニメーションが実行されます。

しかし UIView が保持している CALayer に同じことをしても何も起こりません。ただ新しいフレームにアニメーション無しで移るだけです。

なぜかというと UIView が自身が保持している layer のアニメーション機能をオフにしてるからなんですね。

これは Core Animation Programming Guide - How to Animate Layer-Backed Views にも書かれています。

The UIView class disables layer animations by default but reenables them inside animation blocks.

理由としては Core Text などと同様、 iPhone OS リリース当時のパフォーマンスが芳しくなかったからとかだと想像します。

どうやってオフにしてるかというと、CALayerCALayerDelegate 経由で UIViewCAAnimation を問い合わせる時に行っています。

で、それを簡単に有効にするために登場したのが、冒頭に書いた UIView Animation Block というわけですね。

上記の事柄は、以下のコードをみると一目瞭然だと思います。

NSLog(@“Animation Block 外: %@",
      [view actionForLayer:view.layer forKey:@"position"]);

[UIView animateWithDuration:1.0 animations:^{
    NSLog(@“Animation Block 内: %@",
          [view actionForLayer:view.layer forKey:@"position"]);
}];

— 実行結果
Animation Block 外: <null>
Animation Block 内: <CABasicAnimation: 0x8c2ff10>

まとめると、

UIView Animation Block の中で animatable property を変更した場合のみ、CAAnimation が発行され暗黙的アニメーションが実行されている

ということでした。長々と失礼しました。

参考リンク

Multiple Animations

View Layer Synergy