お久しぶりです。モバイル基盤部のヴァンサン(@vincentisambart)です。
iOS標準のボタンクラスUIButton
が10年前に作られたものであって、当時存在していなかったAuto LayoutやDynamic Typeとの相性がよくありません。
Auto Layout、Dynamic Type、複数行表示、を活用するカスタムなボタンクラスを作ってみれば少し勉強になるかもしれません。
因みにDynamic Typeはあまり使われていない機能だと思われることがあるようですが、気になって調べてみたら、クックパッドのiOSアプリのユーザーの中で、3分の1がシステム標準でない文字サイズを使っていました。その半分が標準より小さい設定を使っていて、もう半分が標準より大きい設定を使っています。「さらに大きな文字」を有効にすると選べる「アクセシビリティサイズ」を使っているユーザーは全ユーザーの1%未満でした。
まずはシンプルに
ボタンを作るとき、適切な親クラスを考えるとき、UIButton
が最初に頭に浮かぶかもしれません。しかし、UIButton
の標準のサブビュー(titleLabel
やimageView
)の配置はAuto LayoutやUIStackView
を活用できませんし、ボタンに別のUILabel
を入れるとUIButton
標準のtitleLabel
も残っていて分かりにくいと思います。
UIButton
の代わりにその親クラスであるUIControl
を使ってみましょう。実は、UIButton
に期待されている挙動の多くはUIControl
がやってくれます。
カスタムボタンベースは以下のコードでいかがでしょうか。
public final class MyCustomButton: UIControl { private static let cornerRadius: CGFloat = 4 private let titleLabel = UILabel() private func setUp() { // ユーザーの文字サイズの設定によってサイズの変わるフォントを使います // `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです titleLabel.font = UIFont.preferredFont(forTextStyle: .headline) // Dynamic Typeの設定が変わるたびに、上記のフォントのサイズを新しい設定に合わせてほしいです。 // 自動調整を有効にするには、この指定だけでなくフォントを`UIFont.preferredFont(forTextStyle:)`または`UIFontMetrics.default.scaledFont(for:)`で作成する必要があります。 titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 // 行数制限なし titleLabel.textAlignment = .center // titleLabelがボタン全体を覆うように titleLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true // 角丸を忘れず layer.cornerRadius = Self.cornerRadius clipsToBounds = true // 色をつけておく backgroundColor = .orange titleLabel.textColor = .white } public override init(frame: CGRect) { super.init(frame: frame) setUp() } public required init?(coder: NSCoder) { super.init(coder: coder) setUp() } public var title: String { get { titleLabel.text ?? "" } set { titleLabel.text = newValue } } }
実行してみると以下のようになります。
上記のコードだけでも、addTarget
を使ってみればちゃんと動きます。ただし、ボタンを押すとタッチフィードバックがないので改善が少し必要です。
色変更
ボタンの色は押されているかどうかだけではなく、無効(disabled
)になっているかどうかでも色が変わります。
色に影響ある状態を表現するためのenum
を用意しておきましょう。
// `UIControl.State`と違って、この`enum`にはこのボタンの表示に影響ある状態しか入っていません。 private enum DisplayState { case disabled case enabled case enabledHighlighted } private var displayState: DisplayState { // `isEnabled`と`isHighlighted`は`UIControl`の標準のプロパティです。 if isEnabled { if isHighlighted { return .enabledHighlighted } else { return .enabled } } else { return .disabled } }
その状態によって色を変えたいので、色を変えてくれるメソッドを用意しておきましょう。 以下のコードは選んだ色がちょっと適当ですし、文字や背景の色だけではなく、ふちの色も変えても良いかもしれないので、見た目に関してデザイナーに相談しても良いかもしれません。
private func updateColors() { let textColor: UIColor let backgroundColor: UIColor switch displayState { case .disabled: textColor = .white backgroundColor = UIColor.white.darkened case .enabled: textColor = .white backgroundColor = .orange case .enabledHighlighted: textColor = UIColor.white.darkened backgroundColor = UIColor.orange.darkened } self.backgroundColor = backgroundColor titleLabel.textColor = textColor }
因みに上記のdarkened
の定義は以下の通りです。もっと正しい計算があるかもしれませんが、ここはこれで十分でしょう。
private extension UIColor { var darkened: UIColor { let darkeningRatio: CGFloat = 0.9 var hue: CGFloat = 0 var saturation: CGFloat = 0 var brightness: CGFloat = 0 var alpha: CGFloat = 0 if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) { return UIColor( hue: hue, saturation: saturation, brightness: brightness * darkeningRatio, alpha: alpha ) } else { return self } } }
updateColors()
を用意するだけではなく、正しいタイミングで呼ぶ必要もあります。
setUp()
の最後で呼ぶのはもちろん、状態が変わるタイミングでも呼んでおきましょう。
public override var isHighlighted: Bool { didSet { updateColors() } } public override var isEnabled: Bool { didSet { updateColors() } }
ボタンが押されている間に色が変わるようになりました。
ボタンが無効のときも色がちゃんと変わります。
サブタイトルと余白
タイトルだけではなく、サブタイトルも追加しておきましょう。そしてその周りに余白を入れておきましょう。
private let titleLabel = UILabel() private let subtitleLabel = UILabel() // シンプルさのためにinsetsを固定にしてあるが、変えられるようにした方が良さそう private static let insets = NSDirectionalEdgeInsets( top: 5, leading: 5, bottom: 5, trailing: 5 ) private func setUp() { // ユーザーの文字サイズの設定によってサイズの変わるフォントを使います // `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです titleLabel.font = UIFont.preferredFont(forTextStyle: .headline) titleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) subtitleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 // 行数制限なし titleLabel.textAlignment = .center subtitleLabel.numberOfLines = 0 // 行数制限なし subtitleLabel.textAlignment = .center let verticalStackView = UIStackView() verticalStackView.axis = .vertical verticalStackView.alignment = .center verticalStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(verticalStackView) // 左右上下の制約にinsetsの値を活用しても良いのですが、今回はUIStackView.directionalLayoutMarginsを使ってみました verticalStackView.topAnchor.constraint(equalTo: topAnchor).isActive = true verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true // stack view内に余白を少し入れておきます verticalStackView.isLayoutMarginsRelativeArrangement = true verticalStackView.directionalLayoutMargins = Self.insets verticalStackView.addArrangedSubview(titleLabel) verticalStackView.addArrangedSubview(subtitleLabel) // stack viewのおかげで隠れたビューがスペースをとりません subtitleLabel.isHidden = true layer.cornerRadius = Self.cornerRadius clipsToBounds = true updateColors() } public var subtitle: String { get { subtitleLabel.text ?? "" } set { subtitleLabel.text = newValue subtitleLabel.isHidden = newValue.isEmpty } }
もちろんupdateColors()
の最後にsubtitleLabel
の色の更新も必要ですね。
subtitleLabel.textColor = textColor
タップ反応
見た目は大丈夫そうに見えるが、試してみたら、なぜかタップするとき反応しなくなりました…
実は、タップはverticalStackView
が全部受け取るようになりました。タップがボタン自体にたどり着きません。
以前動いていたのはUILabel
のisUserInteractionEnabled
が標準でfalse
だからです。UIStackView
はシンプルなUIView
のようにisUserInteractionEnabled
が標準でtrue
です。
setUp()
の中で以下の1行を入れておけば上手く動くようになります。
verticalStackView.isUserInteractionEnabled = false // タッチイベントはこのボタンまで来てほしい
このボタンの中のタップが全部ボタンにたどり着いてほしいので、stackView.isUserInteractionEnabled = false
が良いのですが、UIStackView
の中のものにたどり着いてほしければ使えません。
これでボタンがちゃんと動くはずです。あとはレイアウトは自分のニーズに合わせて色々できます。
UIButton
を使わないおかげで、不要なサブビューが作られることはないが、UIButton
がやってくれて、UIControl
がやってくれない機能を失ってしまう。その機能の1つがアクセシビリティです。
アクセシビリティ
アクセシビリティとは利用しやすさ、もっと多くの人がもっと多くの状況でアプリを使えるのを目指すことだと言っても良いのかな。今の自分がアプリを問題なく使えたとしても、メガネのない時の自分、30年後の自分、自分の親戚、にはアクセシビリティ機能が必要かもしれません。
上記のコードにadjustsFontForContentSizeCategory = true
が入っていて、Dynamic Typeというアクセシビリティ機能の一つを既に活用しています。
でもVoice Overなど、画面の中身を見て操作できるアクセシビリティ機能にとって、各ビューがどういうものなのか、どういう風に使えるのか、知るすべが必要です。
上記のコードのままだと一応Voice Overで操作はできるけど、「ボタン」として認識されていないので、操作できることに気づかれないかもしれません。
今回、アクセシビリティ対応は難しいことではありません:
- 標準の
UIControl
が「accessibility element」ではないので、アクセシビリティ機能に無視されてしまいます。isAccessibilityElement = true
で認識されるようになります。 - このビューがボタンであることを
accessibilityTraits = .button
でシステムに伝えましょう。 isAccessibilityElement = true
をやったことで、Voice Overが中に入っているUILabel
を音読しなくなるので、accessibilityLabel
でボタンの中身を伝えましょう。\ 因みにUIButton
がaccessibility elementなので、UIButton
の中にUILabel
を入れるときも同じ問題が起きます。- ボタンに画像しか入っていないときでも、何をやるボタンなのか分かるすべがないので
accessibilityLabel
にひとことを入れておきましょう。
以下のようになります。
isAccessibilityElement = true var accessibilityTraits: UIAccessibilityTraits = .button if !isEnabled { accessibilityTraits.insert(.notEnabled) } self.accessibilityTraits = accessibilityTraits accessibilityLabel = [title, subtitle].filter { !$0.isEmpty }.joined(separator: "\n")
もちろん上記のコードはtitle
、subtitle
、isEnabled
の変更時に呼んで情報を更新する必要がありますね。
最後に
iOSクックパッドアプリでは、このボタンの拡張したバージョンが一部の画面で使われています。 作った時、細かいところいくつかに引っかかったので、この記事が少しでも役に立っていただければと思って書いてみました。
iOSクックパッドアプリのDynamic Type対応はまだ対応していない画面がまだありますが、少しずつ改善していこうとしています。
すべてのコードを以下にまとめておきました。このコードをご自由に自分のアプリにお使いください。\ 必要であれば、ライセンスがないと困る人のためにちゃんとしたライセンスも入れておきました。
// This project is licensed under the MIT license. // // Copyright (c) 2020 Cookpad Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. public final class MyCustomButton: UIControl { private static let cornerRadius: CGFloat = 4 private let titleLabel = UILabel() private let subtitleLabel = UILabel() private static let insets = NSDirectionalEdgeInsets( top: 5, leading: 5, bottom: 5, trailing: 5 ) private func setUp() { // ユーザーの文字サイズの設定によってサイズの変わるフォントを使う // `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです titleLabel.font = UIFont.preferredFont(forTextStyle: .headline) titleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) subtitleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 // 行数制限なし titleLabel.textAlignment = .center subtitleLabel.numberOfLines = 0 // 行数制限なし subtitleLabel.textAlignment = .center let verticalStackView = UIStackView() verticalStackView.axis = .vertical verticalStackView.alignment = .center verticalStackView.isUserInteractionEnabled = false // タッチイベントはこのボタンまで来てほしい verticalStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(verticalStackView) // 左右上下の制約にinsetsの値を活用しても良いのですが、今回はUIStackView.directionalLayoutMarginsを使ってみました verticalStackView.topAnchor.constraint(equalTo: topAnchor).isActive = true verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true // stack view内に余白を少し入れておきます verticalStackView.isLayoutMarginsRelativeArrangement = true verticalStackView.directionalLayoutMargins = Self.insets verticalStackView.addArrangedSubview(titleLabel) verticalStackView.addArrangedSubview(subtitleLabel) // stack viewのおかげで隠れたビューがスペースをとりません subtitleLabel.isHidden = true layer.cornerRadius = Self.cornerRadius clipsToBounds = true updateColors() updateAccessibility() } private enum DisplayState { case disabled case enabled case enabledHighlighted } private var displayState: DisplayState { if isEnabled { if isHighlighted { return .enabledHighlighted } else { return .enabled } } else { return .disabled } } private func updateColors() { let textColor: UIColor let backgroundColor: UIColor switch displayState { case .disabled: textColor = .white backgroundColor = .lightGray case .enabled: textColor = .white backgroundColor = .orange case .enabledHighlighted: textColor = UIColor.white.darkened backgroundColor = UIColor.orange.darkened } self.backgroundColor = backgroundColor titleLabel.textColor = textColor subtitleLabel.textColor = textColor } public override var isHighlighted: Bool { didSet { updateColors() } } public override var isEnabled: Bool { didSet { updateColors() updateAccessibility() } } public override init(frame: CGRect) { super.init(frame: frame) setUp() } public required init?(coder: NSCoder) { super.init(coder: coder) setUp() } public var title: String { get { titleLabel.text ?? "" } set { titleLabel.text = newValue updateAccessibility() } } public var subtitle: String { get { subtitleLabel.text ?? "" } set { subtitleLabel.text = newValue subtitleLabel.isHidden = newValue.isEmpty updateAccessibility() } } private func updateAccessibility() { isAccessibilityElement = true var accessibilityTraits: UIAccessibilityTraits = .button if !isEnabled { accessibilityTraits.insert(.notEnabled) } self.accessibilityTraits = accessibilityTraits accessibilityLabel = [title, subtitle].filter { !$0.isEmpty }.joined(separator: "\n") } }