【断酒iOSアプリ制作】飲酒量入力画面をつくる (3日目)

この記事のゴール

飲酒量を入力できるシンプルな画面を作成し、ユーザーが飲酒状況を直感的に記録できるようにすることを目指します。この画面は、断酒や飲酒管理をサポートするアプリの重要な部分となります。

ゴール
ゴール

飲酒量入力画面の概要

本日は以下の3つのポイントに焦点を当てて進めます。

飲酒の有無を選択できるボタンの作成

「はい」「いいえ」のボタンを使って飲酒の有無を選択し、選択内容に応じて飲酒量入力画面を切り替える仕組みを実装します。

飲酒量の入力インターフェース

飲酒量を選択できる AlcoholSelectionView を作成します。各アルコール飲料をタップして追加できるUIを実装します。

履歴の管理と操作性の向上

Undoボタンを実装し、選択した内容を取り消せるようにします。また、入力内容を可視化し、合計の純アルコール量をリアルタイムで計算します。

飲酒量入力画面を構築する

FormViewController

FormViewController は、飲酒量入力画面の主要なコントローラーです。以下の機能を実装しています。

  • 日付表示: 日付ラベルで記録する日を明示。
  • 飲酒の有無の選択: 「はい」「いいえ」のボタンを表示し、選択結果に応じて飲酒量入力画面を表示/非表示にする。
  • 動的なボタンスタイル: 選択されたボタンにハイライトを適用。

ソースコード

以下は FormViewController の実装コードです。

swift
import UIKit

class FormViewController: UIViewController {
    var selectedDay: Date? // 選択された日付を保持するプロパティ

    private let dayLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
        label.textAlignment = .center
        label.textColor = .black
        return label
    }()

    private let questionLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = UIFont.systemFont(ofSize: 24, weight: .semibold)
        label.textAlignment = .center
        label.textColor = .black
        label.text = "お酒を飲みましたか?"
        return label
    }()

    private let yesButton: UIButton = {
        let button = UIButton(type: .system)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("はい", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: 20, weight: .bold)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = UIColor.fromHex("#CCCCCC")
        button.layer.cornerRadius = 50
        return button
    }()

    private let noButton: UIButton = {
        let button = UIButton(type: .system)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("いいえ", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: 20, weight: .bold)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = UIColor.fromHex("#CCCCCC")
        button.layer.cornerRadius = 50
        return button
    }()

    private let alcoholSelectionView: AlcoholSelectionView = {
        let view = AlcoholSelectionView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.isHidden = true // 初期状態で非表示
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        setupUI()
        setupActions()
    }

    private func setupUI() {
        view.addSubview(dayLabel)
        view.addSubview(questionLabel)
        view.addSubview(yesButton)
        view.addSubview(noButton)
        view.addSubview(alcoholSelectionView)

        NSLayoutConstraint.activate([
            dayLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            dayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),

            questionLabel.topAnchor.constraint(equalTo: dayLabel.bottomAnchor, constant: 40),
            questionLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),

            yesButton.topAnchor.constraint(equalTo: questionLabel.bottomAnchor, constant: 40),
            yesButton.trailingAnchor.constraint(equalTo: view.centerXAnchor, constant: -20),
            yesButton.widthAnchor.constraint(equalToConstant: 100),
            yesButton.heightAnchor.constraint(equalToConstant: 100),

            noButton.topAnchor.constraint(equalTo: questionLabel.bottomAnchor, constant: 40),
            noButton.leadingAnchor.constraint(equalTo: view.centerXAnchor, constant: 20),
            noButton.widthAnchor.constraint(equalToConstant: 100),
            noButton.heightAnchor.constraint(equalToConstant: 100),

            alcoholSelectionView.topAnchor.constraint(equalTo: yesButton.bottomAnchor, constant: 40),
            alcoholSelectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0),
            alcoholSelectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0),
//            alcoholSelectionView.heightAnchor.constraint(equalToConstant: 200)
            alcoholSelectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10) // 下端に余白を取る
        ])

        if let selectedDay = selectedDay {
            let formatter = DateFormatter()
            formatter.dateFormat = "yyyy年MM月dd日"
            dayLabel.text = "\(formatter.string(from: selectedDay))"
        }
    }

    private func setupActions() {
        yesButton.addTarget(self, action: #selector(didTapYes), for: .touchUpInside)
        noButton.addTarget(self, action: #selector(didTapNo), for: .touchUpInside)
    }

    @objc private func didTapYes() {
        highlightButton(yesButton, isSelected: true)
        highlightButton(noButton, isSelected: false)
        alcoholSelectionView.isHidden = false // 「はい」で表示
    }

    @objc private func didTapNo() {
        highlightButton(yesButton, isSelected: false)
        highlightButton(noButton, isSelected: true)
        alcoholSelectionView.isHidden = true // 「いいえ」で非表示
    }

    private func highlightButton(_ button: UIButton, isSelected: Bool) {
        if isSelected {
            button.backgroundColor = UIColor.systemBlue // ハイライト色
            button.setTitleColor(.white, for: .normal)
        } else {
            button.backgroundColor = UIColor.fromHex("#CCCCCC") // デフォルト色
            button.setTitleColor(.white, for: .normal)
        }
    }
}

extension UIColor {
    static func fromHex(_ hex: String, alpha: CGFloat = 1.0) -> UIColor {
        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")

        var rgb: UInt64 = 0
        Scanner(string: hexSanitized).scanHexInt64(&rgb)

        let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
        let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
        let blue = CGFloat(rgb & 0x0000FF) / 255.0

        return UIColor(red: red, green: green, blue: blue, alpha: alpha)
    }
}

AlcoholSelectionView

AlcoholSelectionView は、選択可能なアルコール飲料のリストと、選択された飲料の合計純アルコール量を表示するカスタムビューです。飲料を追加するたびに合計が更新され、Undoボタンで選択を取り消すことができます。

主なポイント

  • 純アルコール量の計算: 飲料ごとのアルコール量を計算し、合計値を表示します。
  • Undo機能: 履歴を管理して、一つ前の状態に戻れるようにします。
  • 横スクロールビュー: 横スクロール可能な飲料リストを UICollectionView で実装しています。

ソースコード

以下は AlcoholSelectionView の実装コードです。

swift
import UIKit

class AlcoholSelectionView: UIView {
    private let alcoholOptions: [(name: String, pureAlcoholContent: Double)] = [
        ("ビール 500", 20.0),   // ビール5% * 500ml * 0.8 (エタノール比重)
        ("ビール 350", 14.0),   // ビール5% * 350ml * 0.8
        ("ワイン 1本", 96.0),   // ワイン12% * 750ml * 0.8
        ("ワイン 1杯", 19.2),   // ワイン12% * 150ml * 0.8
        ("ストロング 9% 500", 36.0),   // 焼酎9% * 500ml * 0.8
        ("ストロング 9% 350", 25.2),   // 焼酎9% * 350ml * 0.8
        ("ストロング 7% 500", 28.0),   // 焼酎7% * 500ml * 0.8
        ("ストロング 7% 350", 19.6),   // 焼酎7% * 350ml * 0.8
        ("ウイスキー シングル", 9.6), // ウイスキー40% * 30ml * 0.8
        ("ウイスキー ダブル", 20.0), // ウイスキー40% * 60ml * 0.8
        ("その他", 0.0)
    ]
    
    private var selectedItems: [(name: String, count: Int)] = [] // 選択されたお酒とその本数を管理・タップ順序を保持
    private var totalPureAlcoholContent: Double = 0.0 {
        didSet {
            let truncatedValue = floor(totalPureAlcoholContent * 10) / 10 // 小数点1桁で切り捨て
            totalLabel.text = "合計純アルコール量\n\(truncatedValue)g" // 数字部分を改行
        }
    }
    
    private var historyStack: [(selectedItems: [(name: String, count: Int)], totalPureAlcoholContent: Double)] = [] // 履歴管理
    
    private let totalLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 22, weight: .semibold)
        label.textAlignment = .center
        label.text = "合計純アルコール量\n0g" // 改行をデフォルトで挿入
        label.numberOfLines = 0 // 折り返しを有効にする
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private let listLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 16, weight: .regular)
        label.textAlignment = .center
        label.numberOfLines = 0
        label.text = ""
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var undoButton: UIButton = {
        let button = UIButton(type: .system)
        let icon = UIImage(systemName: "arrow.uturn.left") // Undoアイコン
        button.setImage(icon, for: .normal)
        button.tintColor = .white // アイコンの色
        button.backgroundColor = .lightGray // 薄灰色の背景
        button.layer.cornerRadius = 20 // 丸みを調整
        button.addTarget(self, action: #selector(didTapUndoButton), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal // 横スクロールに設定
        layout.estimatedItemSize = CGSize(width: 50, height: 40) // 推定サイズを指定して自動調整
        layout.minimumLineSpacing = 10
        layout.minimumInteritemSpacing = 10
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.register(AlcoholOptionCell.self, forCellWithReuseIdentifier: AlcoholOptionCell.identifier)
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.showsHorizontalScrollIndicator = true
        collectionView.backgroundColor = .clear
        return collectionView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }
    
    private func setupView() {
        backgroundColor = .white
        
        addSubview(totalLabel)
        addSubview(listLabel)
        addSubview(undoButton)
        addSubview(collectionView)
        
        NSLayoutConstraint.activate([
            totalLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
            totalLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
            totalLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
            
            undoButton.topAnchor.constraint(equalTo: totalLabel.bottomAnchor, constant: 10), // undoButtonをtotalLabelの下に配置
            undoButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
            undoButton.widthAnchor.constraint(equalToConstant: 40),
            undoButton.heightAnchor.constraint(equalToConstant: 40),
            
            listLabel.topAnchor.constraint(equalTo: undoButton.bottomAnchor, constant: 10), // listLabelをundoButtonの下に配置
            listLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
            listLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
            
            collectionView.topAnchor.constraint(equalTo: listLabel.bottomAnchor, constant: 20), // collectionViewをlistLabelの下に配置
            collectionView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
            collectionView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
            collectionView.heightAnchor.constraint(equalToConstant: 60), // 高さを固定
            collectionView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10)
        ])
    }
    
    private func saveCurrentStateToHistory() {
        historyStack.append((selectedItems: selectedItems, totalPureAlcoholContent: totalPureAlcoholContent))
    }
    
    
    @objc private func didTapUndoButton() {
        guard let previousState = historyStack.popLast() else { return } // 履歴が空なら何もしない
        selectedItems = previousState.selectedItems
        totalPureAlcoholContent = previousState.totalPureAlcoholContent
        updateListLabel() // ラベルを更新
    }
    
    private func updateListLabel() {
        if selectedItems.isEmpty {
            listLabel.text = ""
        } else {
            listLabel.text = selectedItems.map { "\($0.name) x \($0.count)" }.joined(separator: "\n")
        }
    }
}

// MARK: - UICollectionViewDataSource
extension AlcoholSelectionView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return alcoholOptions.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AlcoholOptionCell.identifier, for: indexPath) as? AlcoholOptionCell else {
            return UICollectionViewCell()
        }
        let option = alcoholOptions[indexPath.item]
        cell.configure(with: option.name)
        return cell
    }
}

// MARK: - UICollectionViewDelegate
extension AlcoholSelectionView: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        saveCurrentStateToHistory() // 現在の状態を保存
        let option = alcoholOptions[indexPath.item]
        
        // 既存項目を探す
        if let index = selectedItems.firstIndex(where: { $0.name == option.name }) {
            selectedItems[index].count += 1 // カウントを更新
        } else {
            selectedItems.append((name: option.name, count: 1)) // 新しい項目を追加
        }
        
        totalPureAlcoholContent += option.pureAlcoholContent
        updateListLabel()
    }
}

// MARK: - AlcoholOptionCell
class AlcoholOptionCell: UICollectionViewCell {
    static let identifier = "AlcoholOptionCell"
    
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 16, weight: .bold)
        label.textColor = .white
        label.textAlignment = .center
        label.numberOfLines = 1
        label.setContentCompressionResistancePriority(.required, for: .horizontal) // 圧縮防止
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(titleLabel)
        contentView.backgroundColor = UIColor.systemBlue
        contentView.layer.cornerRadius = 20
        contentView.layer.masksToBounds = true
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
            titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),
            titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10)
        ])
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    func configure(with title: String) {
        titleLabel.text = title
    }
}

UIの構成と機能説明

飲酒有無ボタン

「はい」「いいえ」のボタンを実装し、ユーザーが飲酒の有無を選択できるようにしています。選択したボタンは背景色を変更してハイライトします。

アルコール選択ビュー

AlcoholSelectionView 内では、以下の操作が可能です。

  • 飲料の選択: 横スクロール可能なリストから飲料を選択し、ボタンをタップするごとに本数がカウントされます。
  • 合計純アルコール量の表示: 選択した飲料の純アルコール量を計算し、合計を表示します。
  • Undo機能: 最新の選択を取り消して、直前の状態に戻ることができます。

Undoボタンの動作

swift
@objc private func didTapUndoButton() {
    guard let previousState = historyStack.popLast() else { return }
    selectedItems = previousState.selectedItems
    totalPureAlcoholContent = previousState.totalPureAlcoholContent
    updateListLabel()
}

Undoボタンを押すと、最新の履歴をスタックからポップして、状態を更新します。

swift
    @IBAction func onTappedClick(_ sender: Any) {
        let formVC = FormViewController() // フォームビューのコントローラー
        formVC.selectedDay = Date() // 日付をDate型で渡す
        formVC.modalPresentationStyle = .formSheet // モーダルスタイルを設定
        present(formVC, animated: true, completion: nil)
    }

今後の課題

1. データ保存機能の追加

現在は選択内容がアプリ内に保持されるのみで、永続化されません。次のステップでは、Core Data や UserDefaults を使ったデータ保存機能を実装します。

2. カスタマイズ性の向上

ユーザーが飲料リストを編集できるようにし、新しい飲料や特定の飲料を登録可能にします。

3. 視覚的デザインの改善

現在のUIはシンプルですが、グラフィックやアニメーションを追加することで、より洗練された体験を提供します。

コードを試してみる

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。 GitHub - aragig/ios_sample_dansyu: 断酒カレンダーアプリ制作で使う、部品サンプルです

今回のサンプルアプリは、AlcoholFormSampleAppターゲットをビルドすると再現できます。

まとめ

この記事では、断酒アプリの飲酒量入力画面の基盤を構築しました。この画面を通じて、ユーザーは飲酒の有無を記録し、選択した飲酒量を視覚的に確認できます。

今後は、データ保存や分析機能を追加して、より実用的なアプリを目指します。お楽しみに!

関連記事

最後までご覧いただきありがとうございます!

▼ 記事に関するご質問やお仕事のご相談は以下よりお願いいたします。
お問い合わせフォーム