UIStackViewでパディングが思い通りにならない時に試したこと

UIStackViewでパディングが思い通りにならない時に試したこと
Pocket

UIStackView なるものがある。
沢山のViewをまとめたり、動的に中身が変わるようなものでもデザインをうまく制御したりするのにとても便利だ。

その反面、Constraintsを雑に設定しても反映されないことがあるので多少の慣れが必要となる。今回は複雑な処理をしたときにパディングが思い通りにならなかったことと、解決策、またその過程で調べたことを書いていこうと思う。

問題点と解決策

まずは何が起こっていたのかについて説明する。簡略化するために以降のUIStackViewは全てHorizontalなもの、つまり横一列に並んでいるものとする。

問題点は一言で言うと「高さの違うViewを入れたStackViewにおいて、isHiddenによって表示と非表示を切り替えた際に高さが自動調節されない」というものである。

そして解決策もついでに言っておいてしまうとUIStackViewのalignmentをfillからcenterに変えることである。

以降でもう少し詳しく説明する。

UIStackViewでは中身のUIViewのisHiddenプロパティをtrueにしたりfalseにしたりすると、余計な隙間を自動的に詰めてくれるという仕様になっている。

例えばUIStackViewにUILabelを3つ入れてみよう。こんな感じになる。

ここから二番目のlabelのisHiddenをtrueにしてみよう。こうなる。

これは想定通りの挙動だ。

しかし、StackViewに高さの違うLabelを3つ入れた場合。

こうなる。これはStackViewのalignmentプロパティがfillの場合。ここで3番目のLabelのisHiddenをtrueにしてみると、

パディング取られすぎじゃね?!と、こうなるわけだ。本当ならば、以下のようになって欲しい。

こちらは高さがぴったりで、上下の制約ともうまく行きそうである。

しかし実際にはisHiddenによるコンテンツの表示によって、横幅は自動的に調整されるが、縦幅は自動的に調整されないのである。

なぜこのようになっているのかというと、StackViewのalignmentがfillになっているからである。これをcenterやbottomなどにすると、以下のように想定通りの挙動になる。

これで解決。

あれ?さっきの図とちょっと違うぞ?そうなんです。alignmentをfillから変更するとUILabelが伸ばされずに、intrinsic size(文字によって決められたサイズ)のままになるんです。

実は先程の図ではalignmentがfillのままになっています。それでもなんとかパディングを調整する方法が存在していて、そのことについてこれから話して行きます。

目の前の問題を解決するだけじゃなくて、周辺の知識をつけるのはめちゃめちゃ大切なのです。

StackViewの便利メソッドたち

StackViewが持っているメソッドを見ていきましょう。まずは、addArrangedSubview関数です。

これはStackViewの最後の要素としてViewを突っ込んでくれる関数です。

UIViewはもともとsubviewsというプロパティを持っていますが、UIStackViewは加えてarrangedSubviewsというプロパティを持っていて、こっちでStackViewが制御するViewを管理しています。

何も問題がない、素直な関数です。

次に、removeArrangedSubviewという関数があります。これがなかなかの曲者です。試しに三番目のLabelをremoveしてみましょう。

あれ、一番目と二番目が…消えた?騙されてはいけません。(僕は騙されました。)すけすけゴーグルを手に入れて、Labelを透過してみましょう。(label.alpha = 0.5にしてみる。)

removeArrangedSubviewのドキュメントを読んでみると、arrangedSubviewsからは外すけど、subviewsからは外れないと書いています。

https://developer.apple.com/documentation/uikit/uistackview/1616235-removearrangedsubview より

このままでは消したはずのラベルが邪魔なので、label3.isHidden = trueにしてみると、先程の図のようなレイアウトになりました。

また、上記のレイアウトを得るためには、UILabelで親クラスであるUIViewのメソッドremoveFromSuperviewを呼び出しても良いです。

removeFromSuperviewについて少し詳しく見てみましょう。

removeFromSuperviewを試す

removeFromSuperview関数のドキュメントをまず見てみましょう。

https://developer.apple.com/documentation/uikit/uiview/1622421-removefromsuperview より

いい感じにviewを親のviewから取り除いてくれるみたいですね。思い通りのデザインを得るためにはこれでも良いかもしれません。ただし、このメソッドを使う際には少し注意することがあります。

Storyboardを使用していて、IBOutletでweak varとして(弱参照で)ViewControllerに結びつけている場合、Storyboardで生成されたView(今回はUILabel)の参照カウントは1になっているはずです。(実際にはStoryboardで沢山参照されていてカウントが1ではないかもしれませんが、Storyboardでまとめて1つと考えます。)

この時、weak varとしてViewControllerで参照しているので、ViewControllerにおける参照では参照カウントは増加しません。

よって、removeFromSuperviewを実行してStoryboardにおける参照がなくなった結果、Viewのインスタンスは破棄されることになるのです。ここで、

@IBOutlet weak var label3: UILabel!

という風に変数宣言でアンラップされていると

label3.text

などプロパティにアクセスするタイミングなどでクラッシュすることになってしまいます。(余談:暗黙的にアンラップされるオプショナル型に過ぎないなので、print(label3)はnilを返す)

なので、この場合は弱参照ではなく強参照、つまり

@IBOutlet var label3: UILabel!

という風にweakを外すことによって、強制アンラップの危険性を取り除いてあげる必要があるケースもあるかもしれません。

このようにすることで、Storyboardで生成された参照カウントが全て消えたとしてもViewControllerにおける強参照のおかげでARCによってUILabelのインスタンスが消去されることはなく、labelにnilが入ることは無くなるのです。

最後に

一番良いのはalignmentをcenterやbottomなどに変更して対応することだと思います。それでもどうしてもデザインが気に食わない場合は、上記に挙げたメソッドを試してみてください。

Pocket