お久しぶりです。モバイル基盤部のヴァンサン(@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() {
titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0
titleLabel.textAlignment = .center
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
を用意しておきましょう。
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 = 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()
private static let insets = NSDirectionalEdgeInsets(
top: 5,
leading: 5,
bottom: 5,
trailing: 5
)
private func setUp() {
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)
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
verticalStackView.isLayoutMarginsRelativeArrangement = true
verticalStackView.directionalLayoutMargins = Self.insets
verticalStackView.addArrangedSubview(titleLabel)
verticalStackView.addArrangedSubview(subtitleLabel)
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対応はまだ対応していない画面がまだありますが、少しずつ改善していこうとしています。
すべてのコードを以下にまとめておきました。このコードをご自由に自分のアプリにお使いください。\
必要であれば、ライセンスがないと困る人のためにちゃんとしたライセンスも入れておきました。
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() {
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)
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
verticalStackView.isLayoutMarginsRelativeArrangement = true
verticalStackView.directionalLayoutMargins = Self.insets
verticalStackView.addArrangedSubview(titleLabel)
verticalStackView.addArrangedSubview(subtitleLabel)
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")
}
}