iOSでモダンなカスタムボタンを作ってみよう

お久しぶりです。モバイル基盤部のヴァンサン(@vincentisambart)です。

iOS標準のボタンクラスUIButtonが10年前に作られたものであって、当時存在していなかったAuto LayoutやDynamic Typeとの相性がよくありません。

Auto Layout、Dynamic Type、複数行表示、を活用するカスタムなボタンクラスを作ってみれば少し勉強になるかもしれません。

因みにDynamic Typeはあまり使われていない機能だと思われることがあるようですが、気になって調べてみたら、クックパッドのiOSアプリのユーザーの中で、3分の1がシステム標準でない文字サイズを使っていました。その半分が標準より小さい設定を使っていて、もう半分が標準より大きい設定を使っています。「さらに大きな文字」を有効にすると選べる「アクセシビリティサイズ」を使っているユーザーは全ユーザーの1%未満でした。

まずはシンプルに

ボタンを作るとき、適切な親クラスを考えるとき、UIButtonが最初に頭に浮かぶかもしれません。しかし、UIButtonの標準のサブビュー(titleLabelimageView)の配置は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
        }
    }
}

実行してみると以下のようになります。

f:id:vincentisambart:20200616071827p:plain:w320

上記のコードだけでも、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()
    }
}

ボタンが押されている間に色が変わるようになりました。

f:id:vincentisambart:20200616071836p:plain:w320

ボタンが無効のときも色がちゃんと変わります。

f:id:vincentisambart:20200616071841p:plain:w320

サブタイトルと余白

タイトルだけではなく、サブタイトルも追加しておきましょう。そしてその周りに余白を入れておきましょう。

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

f:id:vincentisambart:20200616071851p:plain:w320

タップ反応

見た目は大丈夫そうに見えるが、試してみたら、なぜかタップするとき反応しなくなりました…

実は、タップはverticalStackViewが全部受け取るようになりました。タップがボタン自体にたどり着きません。 以前動いていたのはUILabelisUserInteractionEnabledが標準で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")

もちろん上記のコードはtitlesubtitleisEnabledの変更時に呼んで情報を更新する必要がありますね。

最後に

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")
    }
}