Blog スタッフブログ

iOS Swift システム開発 ひとくちコードスニペット

[Swift]UIControlのサブクラスでチェックボックスを作ってみた

Swift

こんにちは、株式会社MIXシステム開発担当のBloomです。

今回はUISwitchの存在により標準コンポーネントに採用されていない(筆者の思い込みです)チェックボックスをUIControlのサブクラスとして作ってみましょう。

import UIKit

@MainActor
@IBDesignable
final class CheckboxControl: UIControl {

    @IBInspectable var isOn: Bool = false {
        didSet {
            guard oldValue != isOn else { return }
            updateAppearance(animated: true)
            sendActions(for: .valueChanged)
            UIAccessibility.post(notification: .announcement,
                                 argument: isOn ? "Checked" : "Unchecked")
        }
    }

    @IBInspectable var iconSize: CGFloat = 22 { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
    @IBInspectable var spacing: CGFloat = 8 { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }

    @IBInspectable var onTintColor: UIColor = .systemBlue { didSet { updateAppearance(animated: false) } }
    @IBInspectable var offTintColor: UIColor = .secondaryLabel { didSet { updateAppearance(animated: false) } }

    @IBInspectable var text: String = "" { didSet { label.text = text; invalidateIntrinsicContentSize() } }
    @IBInspectable var textColor: UIColor = .label { didSet { label.textColor = textColor } }

    @IBInspectable var onSymbolName: String = "checkmark.square.fill" { didSet { updateAppearance(animated: false) } }
    @IBInspectable var offSymbolName: String = "square" { didSet { updateAppearance(animated: false) } }

    // MARK: - Views
    private let imageView = UIImageView()
    private let label = UILabel()

    // MARK: - Init
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        Task { @MainActor in
            commonInit()
            updateAppearance(animated: false)
        }
    }

    private func commonInit() {
        isAccessibilityElement = true
        accessibilityTraits = [.button]

        imageView.contentMode = .scaleAspectFit
        imageView.setContentHuggingPriority(.required, for: .horizontal)
        imageView.setContentCompressionResistancePriority(.required, for: .horizontal)

        label.textColor = textColor
        label.numberOfLines = 1

        addSubview(imageView)
        addSubview(label)

        addTarget(self, action: #selector(didTap), for: .touchUpInside)

        updateAppearance(animated: false)
    }

    // MARK: - Layout
    override func layoutSubviews() {
        super.layoutSubviews()

        let h = bounds.height
        let icon = min(iconSize, h)
        let iconY = (h - icon) / 2

        imageView.frame = CGRect(x: 0, y: iconY, width: icon, height: icon)

        let labelX = imageView.frame.maxX + (text.isEmpty ? 0 : spacing)
        let labelW = max(0, bounds.width - labelX)
        label.frame = CGRect(x: labelX, y: 0, width: labelW, height: h)
    }

    override var intrinsicContentSize: CGSize {
        let labelSize = label.intrinsicContentSize
        let w = iconSize + (text.isEmpty ? 0 : spacing + labelSize.width)
        let h = max(iconSize, labelSize.height)
        return CGSize(width: w, height: h)
    }

    // MARK: - Interaction
    @objc private func didTap() {
        isOn.toggle()
    }
    
    override var isHighlighted: Bool {
        didSet {
            let alpha: CGFloat = isHighlighted ? 0.6 : 1.0
            UIView.animate(withDuration: 0.12) { [weak self] in
                self?.alpha = alpha
            }
        }
    }

    // MARK: - Update
    private func updateAppearance(animated: Bool) {
        accessibilityLabel = text.isEmpty ? "Checkbox" : text
        accessibilityValue = isOn ? "On" : "Off"

        let symbolName = isOn ? onSymbolName : offSymbolName
        let tint = isOn ? onTintColor : offTintColor

        let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular)
        let img = UIImage(systemName: symbolName, withConfiguration: config)

        let apply = { [weak self] in
            guard let self else { return }
            imageView.image = img
            imageView.tintColor = tint
            label.text = text
        }

        if animated {
            UIView.transition(with: imageView, duration: 0.15, options: .transitionCrossDissolve) {
                apply()
            }
        } else {
            apply()
        }
    }
}

storyboard上で配置したい時はUIViewを仮置きしてからCheckboxControlクラスを指定してあげましょう。それでは実際に実行してみます。

実行結果

これだけでラベル領域もタップが反応するチェックボックスを実装することができました。良かったですね。