断酒iOSアプリ開発への道

著者画像
Toshihiko Arai
(更新:2024年12月16日)

【開発1日目】カレンダーを表示する

このブログ記事では、断酒に挑戦するために「断酒カレンダーアプリ」をSwiftで制作する方法を紹介します。記事の内容は、カレンダーのUI表示や横スクロールの実装を丁寧に解説し、制作過程を共有することで、同じようなアプリを作りたい方々の参考にしてもらえればと思います。

私ごとですが、1〜2年ほど前から断酒したいなと思い始めて、何度も挑戦しては挫折を繰り返している今日この頃です。 毎日のようにお酒を呑んでいましたが、年齢とともに(2024年現在で43歳)、もうそろそろお酒はいいかなと思い始めてきました。 時代的にも、断酒ブームですよね。若い人たちは飲まないし、WHOは次はお酒を厳しくしていく感じですし。

「酒ログ」というアプリで飲酒記録をつけていたのですが、断酒なかなか継続が難しいのが現状です。それでもまた断酒に挑戦しようと思いまして、今ままでお酒を呑んでいた時間を使って、せっかくなので断酒のカレンダーアプリを作ってみようと決意しました。お酒を我慢しようと思うと断酒は続かないので、何か熱中できることに集中すれば、お酒を飲むことを忘れるので断酒もうまくいくと思います。

さらにせっかくなので、ブログ記事で制作の一部を公開しようと考えました。 今回はカレンダー表示のUI部分の作り方をご紹介いたします。

この記事のゴール

今回の目標は、以下の動画のようにカレンダーを横スクロールで前月や次月に切り替えられるUIを作成することです。 カレンダーは日付と曜日を表示し、現在の日付をハイライトします。さらに、月の切り替えがスムーズに行える仕組みを構築します。

ゴール

UICollectionViewでカレンダーを作成

UICollectionViewを使い、カレンダーのセルを日付で表現します。以下のコードはカレンダーを管理するCalendarMonthViewControllerです。

import UIKit

class CalendarMonthViewController: UIViewController {
    private var collectionView: UICollectionView!
    private var days: [String] = []
    private let weekdays = ["日", "月", "火", "水", "木", "金", "土"]
    private let calendar = Calendar.current
    var currentMonth: Date = Date()

    private let monthLabel: UILabel = { // 西暦と月を表示するラベル
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.textAlignment = .center
        label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
        label.textColor = .black
        return label
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupDays()
        updateMonthLabel()
    }

    func setMonth(date: Date) {
        self.currentMonth = date
    }

    private func setupUI() {
        // 月ラベルの設定
        view.addSubview(monthLabel)
        view.backgroundColor = .white

        NSLayoutConstraint.activate([
            monthLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
            monthLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            monthLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            monthLabel.heightAnchor.constraint(equalToConstant: 40)
        ])
        // カレンダー部分のレイアウト
        let layout = UICollectionViewFlowLayout()
        layout.minimumLineSpacing = 1
        layout.minimumInteritemSpacing = 1
        layout.itemSize = CGSize(width: view.bounds.width / 7 - 1, height: view.bounds.width / 5 - 1)

        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.backgroundColor = .white
        collectionView.register(CustomCalendarCell.self, forCellWithReuseIdentifier: CustomCalendarCell.identifier)
        collectionView.dataSource = self
        collectionView.delegate = self

        view.addSubview(collectionView)

        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: monthLabel.bottomAnchor, constant: 10),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    private func setupDays() {
        let components = calendar.dateComponents([.year, .month], from: currentMonth)
        guard let startOfMonth = calendar.date(from: components) else { return }

        // 1日が始まる曜日
        let weekday = calendar.component(.weekday, from: startOfMonth)
        let daysInMonth = calendar.range(of: .day, in: .month, for: currentMonth)?.count ?? 0

        days = weekdays
        days.append(contentsOf: Array(repeating: "", count: weekday - 1))
        days.append(contentsOf: (1...daysInMonth).map { String($0) })

        collectionView.reloadData()
    }
    
    private func updateMonthLabel() {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy年 MM月"
        monthLabel.text = formatter.string(from: currentMonth)
    }
}

extension CalendarMonthViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return days.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCalendarCell.identifier, for: indexPath) as? CustomCalendarCell else {
            return UICollectionViewCell()
        }
        
        let day = days[indexPath.item]
        let isToday = isToday(dateString: day)
        let column = indexPath.item % 7 // 曜日を判定(0: 日曜日, 6: 土曜日)
        
        var textColor: UIColor = .black
        if column == 0 { // 日曜日
            textColor = .red
        } else if column == 6 { // 土曜日
            textColor = .gray
        }

        cell.configure(day: day, isToday: isToday, textColor: textColor)
        return cell
    }

    private func isToday(dateString: String) -> Bool {
        guard let day = Int(dateString) else { return false }

        let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date())
        let currentComponents = calendar.dateComponents([.year, .month], from: currentMonth)

        return todayComponents.year == currentComponents.year &&
               todayComponents.month == currentComponents.month &&
               todayComponents.day == day
    }
}

extension CalendarMonthViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let selectedDay = days[indexPath.item]
        guard !selectedDay.isEmpty else { return } // 空白セルを無視
        
        presentFormView(for: selectedDay)
    }

    private func presentFormView(for day: String) {
        print(day)
    }
}

ポイント

  1. 月のラベルを表示 - 現在の年月をヘッダーに表示します。
  2. セル配置 - セルごとに曜日や日付を並べ、視覚的にカレンダーを作成します。
  3. 今日の日付をハイライト - 現在の日付を背景色で強調することで、ユーザーに分かりやすく表示します。

カスタムセルの設定

各日付を表示するためのCustomCalendarCellクラスを定義します。このクラスで日付の表示やハイライト処理を行います。

import UIKit

class CustomCalendarCell: UICollectionViewCell {
    static let identifier = "CustomCalendarCell"

    private let dayLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.textAlignment = .center
        label.font = UIFont.systemFont(ofSize: 14, weight: .medium)
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(dayLabel)

        NSLayoutConstraint.activate([
            dayLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), // 上に余白を設定
            dayLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 5),
            dayLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -5),
            dayLabel.heightAnchor.constraint(equalToConstant: 20) // 高さを固定
        ])
        
        contentView.layer.cornerRadius = 10
        contentView.layer.masksToBounds = true
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(day: String, isToday: Bool = false, textColor: UIColor = .black) {
        dayLabel.text = day
        dayLabel.textColor = textColor
        contentView.backgroundColor = isToday ? .systemYellow : .clear
    }
}

今日の日付をハイライトする仕組み

UICollectionViewDataSourceのcellForItemAtメソッドで、現在の日付かどうかを判定し、セルの背景色を変更しています。

extension CalendarMonthViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return days.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCalendarCell.identifier, for: indexPath) as? CustomCalendarCell else {
            return UICollectionViewCell()
        }
        
        let day = days[indexPath.item]
        let isToday = isToday(dateString: day)
        let column = indexPath.item % 7 // 曜日を判定(0: 日曜日, 6: 土曜日)
        
        var textColor: UIColor = .black
        if column == 0 { // 日曜日
            textColor = .red
        } else if column == 6 { // 土曜日
            textColor = .gray
        }

        cell.configure(day: day, isToday: isToday, textColor: textColor)
        return cell
    }

    private func isToday(dateString: String) -> Bool {
        guard let day = Int(dateString) else { return false }

        let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date())
        let currentComponents = calendar.dateComponents([.year, .month], from: currentMonth)

        return todayComponents.year == currentComponents.year &&
               todayComponents.month == currentComponents.month &&
               todayComponents.day == day
    }
}

前月・次月をスムーズに切り替える

UIPageViewControllerの利用

カレンダーの前月・次月を横スクロールで切り替えるために、UIPageViewControllerを使用します。

import UIKit

class CalendarPageViewController: UIViewController {
    private let calendar = Calendar.current
    private var currentMonth: Date = Date()
    private var pageViewController: UIPageViewController!

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

    private func setupPageViewController() {
        // UIPageViewControllerを初期化
        pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
        pageViewController.dataSource = self
        pageViewController.delegate = self

        // 初期ページを設定
        let initialVC = CalendarMonthViewController()
        initialVC.setMonth(date: currentMonth)
        pageViewController.setViewControllers([initialVC], direction: .forward, animated: false, completion: nil)

        // ページビューを親ビューに追加
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.didMove(toParent: self)

        // レイアウト設定
        pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            pageViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0),
            pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

}

extension CalendarPageViewController: UIPageViewControllerDataSource {
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let currentVC = viewController as? CalendarMonthViewController else { return nil }
        guard let previousMonth = calendar.date(byAdding: .month, value: -1, to: currentVC.currentMonth) else { return nil }

        let previousVC = CalendarMonthViewController()
        previousVC.setMonth(date: previousMonth)
        return previousVC
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentVC = viewController as? CalendarMonthViewController else { return nil }
        guard let nextMonth = calendar.date(byAdding: .month, value: 1, to: currentVC.currentMonth) else { return nil }

        let nextVC = CalendarMonthViewController()
        nextVC.setMonth(date: nextMonth)
        return nextVC
    }
}

extension CalendarPageViewController: UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if completed, let currentVC = pageViewController.viewControllers?.first as? CalendarMonthViewController {
            self.currentMonth = currentVC.currentMonth
        }
    }
}

ここで重要なのは、UIPageViewControllerを親ビューに追加し、現在の月のカレンダーを表示させることです。

StoryBoardを使わずコードでUIを生成

最後に、アプリ全体の初期設定をSceneDelegateで行い、コードのみでUIを生成します。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

        let window = UIWindow(windowScene: windowScene)
        let rootViewController = CalendarPageViewController()
        window.rootViewController = rootViewController
        self.window = window
        window.makeKeyAndVisible()
    }

StoryBoardを使わないことで、柔軟なレイアウト変更や動作確認が可能です。

まとめ

この記事では、断酒カレンダーアプリの基本構造を説明しました。以下のステップを進めることで、機能的なカレンダーアプリを構築できます。 1. UICollectionViewでカレンダーを作成 2. 現在の日付をハイライト表示 3. UIPageViewControllerで月の切り替えを実装

断酒を続けるために役立つアプリを一緒に作っていきましょう。次回は、記録機能や通知機能の実装について紹介します!

ダウンロード

このブログ記事で使用しているコードはGitHubで公開していますので、気軽にダウンロードしてご活用ください。 - https://github.com/aragig/ios_sample_dansyu

今回のサンプルカレンダーは、CalenderSampleAppターゲットをビルドすると再現できます。

【開発3日目】飲酒量入力画面をつくる

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

ゴール

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

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

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

ソースコード

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

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ボタンで選択を取り消すことができます。

主なポイント

ソースコード

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

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ボタンの動作

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

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

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

今後の課題

コードを試してみる

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。

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

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

【開発5日目】飲酒量の視覚化 プログレスバーを作る

断酒アプリ制作5日目の本日は、純アルコール量を計算し、飲酒量に応じた色の変化やリスクレベルをわかりやすく表示する「縦型プログレスバー」を作成します。これにより、数値で飲酒量を認識するよりも、ユーザーは自身の飲酒状況を一目で把握できるため、飲み過ぎかどうかの判断がつきやすくなります。

下の動画のような縦型プログレスバーを作ることがゴールになります。

アプリケーションで飲酒量を視覚化するために、純アルコール量を計算し、その危険度をユーザーに示すインターフェースを構築します。本記事では、VerticalProgressBar クラスを作成し、飲酒量を視覚的に表示する方法を解説します。

純アルコール量の計算

純アルコール量を以下の式で計算します。

\[ 純アルコール量 (g) = 飲酒量 (ml) \times アルコール度数 (\%) \times 0.8 \]

この値に基づき、飲酒リスクを段階的に分類し、視覚化することで、飲酒量を直感的に理解できるようにします。

危険度の分類

ちなみに純アルコール量に基づく危険度は、以下のように段階的に分類されます。

純アルコール量 (g/日) 危険度の段階 健康への影響
0~19g 低リスク 健康影響は少ないが、習慣化に注意が必要。
20~39g 中リスク 肝臓疾患や高血圧のリスクがわずかに増加。
40~59g 高リスク アルコール依存症や肝臓疾患のリスク増加。
60g以上 非常に高リスク アルコール依存症、肝硬変、心血管疾患の危険大。

※ 基準は一般的なものです。国や医療機関によって異なる場合があります

注意

プログレスバーの実装 VerticalProgressBar

VerticalProgressBar は飲酒量に応じた進捗を縦型プログレスバーで表示するカスタムビューです。

import UIKit


class VerticalProgressBar: UIView {
    
    private let containerView = UIView() // 背景コンテナ
    private let progressBar = UIView() // プログレスバー
    private let arrowView = UIView() // 三角形の矢印を表示するビュー
    private let currentArrowView = UIView() // 現在の状態を示す矢印
    var maxAlcoholContent: Double = 60.0 // 最大値を設定
    var maxAlcoholContentTitle: String = "非常に高リスク" // 最大値のタイトル
    
    private var internalProgress: Double = 0.0 // プログレスバーの進捗状況
    
    var progress: Double {
        get {
            internalProgress
        }
        set {
            internalProgress = newValue
            updateProgress()
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupViews()
    }
    
    private func setupViews() {
        // 背景コンテナ
        containerView.layer.borderWidth = 1
        containerView.layer.borderColor = UIColor.lightGray.cgColor
        containerView.clipsToBounds = true
        containerView.layer.cornerRadius = 5 // 背景のみ角丸にする
        addSubview(containerView)
        
        // プログレスバー
        progressBar.backgroundColor = .green
        containerView.addSubview(progressBar)
        
        // 最大値を示す矢印を作成
        arrowView.backgroundColor = .clear
        addSubview(arrowView)
        
        // 現在の状態を示す矢印を作成
        currentArrowView.backgroundColor = .clear
        addSubview(currentArrowView)
 
    }
    
    // レイアウトの更新
    override func layoutSubviews() {
        super.layoutSubviews()
        containerView.frame = bounds
        updateProgress()
    }
    
    // プログレスバーの更新
    private func updateProgress() {
        let containerHeight = containerView.bounds.height

        // プログレスバーの高さを計算
        let progressHeight = CGFloat(min(internalProgress / maxAlcoholContent, 1.0)) * containerHeight

        // アニメーションでプログレスバーの増加をスムーズに
        UIView.animate(withDuration: 0.3, animations: {
            self.progressBar.frame = CGRect(
                x: 0,
                y: containerHeight - progressHeight,
                width: self.containerView.bounds.width,
                height: progressHeight
            )
        })

        // 矢印の位置を更新
        let arrowPosition: CGFloat
        if internalProgress > maxAlcoholContent {
            arrowPosition = CGFloat((maxAlcoholContent / internalProgress) * containerHeight)
        } else {
            arrowPosition = CGFloat(containerHeight)
        }

        updateArrow(position: arrowPosition)
        updateCurrentArrow(position: containerHeight - progressHeight)

        // 色のアニメーションを追加
        animateColorChange(for: progressBar, to: calculateColor(for: internalProgress))
    }
    
    // 色のアニメーション
    private func animateColorChange(for view: UIView, to color: UIColor) {
        UIView.transition(with: view, duration: 0.3, options: [.transitionCrossDissolve], animations: {
            view.backgroundColor = color
        })
    }
    
    // 進捗に応じた色を計算
    private func calculateColor(for progress: Double) -> UIColor {
        // 色の範囲(緑→黄色→赤→黒)
        let green = UIColor.green
        let yellow = UIColor.yellow
        let red = UIColor.red
        let black = UIColor.black
        
        if progress <= maxAlcoholContent {
            // 緑→黄色→赤の間
            let normalizedProgress = progress / maxAlcoholContent
            if normalizedProgress < 0.5 {
                return interpolateColor(from: green, to: yellow, fraction: CGFloat(normalizedProgress * 2.0))
            } else {
                return interpolateColor(from: yellow, to: red, fraction: CGFloat((normalizedProgress - 0.5) * 2.0))
            }
        } else {
            // 赤→黒の間
            let overLimitProgress = (progress - maxAlcoholContent) / maxAlcoholContent
            return interpolateColor(from: red, to: black, fraction: CGFloat(min(overLimitProgress, 1.0)))
        }
    }
    
    // 色の補間
    private func interpolateColor(from: UIColor, to: UIColor, fraction: CGFloat) -> UIColor {
        // UIColorのコンポーネントを取得
        var fromRed: CGFloat = 0, fromGreen: CGFloat = 0, fromBlue: CGFloat = 0, fromAlpha: CGFloat = 0
        var toRed: CGFloat = 0, toGreen: CGFloat = 0, toBlue: CGFloat = 0, toAlpha: CGFloat = 0
        
        from.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha)
        to.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha)
        
        // 線形補間で色を計算
        let red = fromRed + (toRed - fromRed) * fraction
        let green = fromGreen + (toGreen - fromGreen) * fraction
        let blue = fromBlue + (toBlue - fromBlue) * fraction
        let alpha = fromAlpha + (toAlpha - fromAlpha) * fraction
        
        return UIColor(red: red, green: green, blue: blue, alpha: alpha)
    }
    
    // 矢印の位置を更新
    private func updateArrow(position: CGFloat) {

        let arrowWidth: CGFloat = 10
        let arrowHeight: CGFloat = 10
        let horizontalBarWidth: CGFloat = containerView.bounds.width // 水平棒の幅

        // 三角形と水平棒を一つのパスで描画
        let path = UIBezierPath()

        // 三角形の描画(右側)
        path.move(to: CGPoint(x: arrowWidth, y: 0))
        path.addLine(to: CGPoint(x: 0, y: arrowHeight / 2))
        path.addLine(to: CGPoint(x: arrowWidth, y: arrowHeight))
        path.close()

        // 水平棒の描画
        path.move(to: CGPoint(x: -horizontalBarWidth, y: arrowHeight / 2)) // 棒の始点
        path.addLine(to: CGPoint(x: 0, y: arrowHeight / 2)) // 棒の終点


        let shapeLayer = CAShapeLayer()
        shapeLayer.path = path.cgPath
        shapeLayer.fillColor = UIColor.black.cgColor
        shapeLayer.strokeColor = UIColor.white.cgColor // 水平棒の色
        shapeLayer.lineWidth = 2.0 // 水平棒の太さ

        arrowView.layer.sublayers?.forEach { $0.removeFromSuperlayer() } // 既存の三角形を削除
        arrowView.layer.addSublayer(shapeLayer)

        // アニメーションで矢印の移動をスムーズに
        UIView.animate(withDuration: 0.3, animations: {
            self.arrowView.frame = CGRect(
                x: self.containerView.frame.maxX + 5,
                y: self.bounds.height - position - (arrowHeight / 2) + 2,
                width: arrowWidth,
                height: arrowHeight
            )
        })


        // ラベルを追加または更新
        if let label = viewWithTag(9001) as? UILabel {
            UIView.animate(withDuration: 0.3, animations: {
                label.frame = CGRect(
                    x: self.arrowView.frame.maxX + 5,
                    y: self.arrowView.frame.midY - 10,
                    width: 80,
                    height: 20
                )
            })
        } else {
            let label = UILabel()
            label.text = maxAlcoholContentTitle
            label.font = UIFont.systemFont(ofSize: 12)
            label.textColor = .black
            label.tag = 9001 // ラベルをタグで管理
            label.frame = CGRect(
                x: arrowView.frame.maxX + 5,
                y: arrowView.frame.midY - 10,
                width: 80,
                height: 20
            )
            addSubview(label)
        }
    }
    
    
    // 現在の状態を示す矢印の位置を更新
    private func updateCurrentArrow(position: CGFloat) {
        let arrowWidth: CGFloat = 10
        let arrowHeight: CGFloat = 10

        // 矢印を三角形に描画(現在の状態を示す矢印)
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: 0))
        path.addLine(to: CGPoint(x: arrowWidth, y: arrowHeight / 2))
        path.addLine(to: CGPoint(x: 0, y: arrowHeight))
        path.close()

        let shapeLayer = CAShapeLayer()
        shapeLayer.path = path.cgPath
        shapeLayer.fillColor = UIColor.black.cgColor

        currentArrowView.layer.sublayers?.forEach { $0.removeFromSuperlayer() } // 既存の三角形を削除
        currentArrowView.layer.addSublayer(shapeLayer)

        // アニメーションで矢印の移動をスムーズに
        UIView.animate(withDuration: 0.3, animations: {
            self.currentArrowView.frame = CGRect(
                x: self.containerView.frame.minX - 15,
                y: position - 5,
                width: arrowWidth,
                height: arrowHeight
            )
        })
        
        // ラベルを追加または更新
        if let label = viewWithTag(9002) as? UILabel {
            // ラベルの値を更新
            label.text = String(format: "%.1fg", internalProgress)
            UIView.animate(withDuration: 0.3, animations: {
                label.frame = CGRect(
                    x: self.currentArrowView.frame.minX - 55, // 三角形の左側に配置
                    y: self.currentArrowView.frame.midY - 10,
                    width: 50,
                    height: 20
                )
            })
        } else {
            let label = UILabel()
            label.text = String(format: "%.1fg", internalProgress)
            label.font = UIFont.systemFont(ofSize: 12)
            label.textColor = .black
            label.textAlignment = .right
            label.tag = 9002 // ラベルをタグで管理
            label.frame = CGRect(
                x: currentArrowView.frame.minX - 55, // 三角形の左側に配置
                y: currentArrowView.frame.midY - 10,
                width: 50,
                height: 20
            )
            addSubview(label)
        }
    }

    // 水平線を描画
    private func drawHorizontalLine(at yPosition: CGFloat) {
        // 既存の水平線を削除
        containerView.layer.sublayers?.removeAll(where: { $0.name == "HorizontalLine" })

        // 水平線を描画
        let linePath = UIBezierPath()
        linePath.move(to: CGPoint(x: 0, y: yPosition))
        linePath.addLine(to: CGPoint(x: containerView.bounds.width, y: yPosition))

        let lineLayer = CAShapeLayer()
        lineLayer.path = linePath.cgPath
        lineLayer.strokeColor = UIColor.white.cgColor // 水平線を白色に設定
        lineLayer.lineWidth = 2.0 // 線の太さを調整
        lineLayer.shadowColor = UIColor.black.cgColor // 影を追加して視認性を向上
        lineLayer.shadowOpacity = 0.8
        lineLayer.shadowRadius = 1
        lineLayer.shadowOffset = CGSize(width: 0, height: 1)
        lineLayer.name = "HorizontalLine" // 識別のため名前を付ける

        containerView.layer.addSublayer(lineLayer)
    }
    
    // リセット機能
    func reset() {
        internalProgress = 0.0
        updateProgress()
        
        // ラベルを削除
        if let label = viewWithTag(9001) as? UILabel {
            label.removeFromSuperview()
        }
    }
}

主なプロパティと機能

カラーの変化

calculateColor(for:) メソッドでは、飲酒量に応じて以下のように色を変化させます:

現在の飲酒量とリスクレベルの表示

updateArrow(position:) と updateCurrentArrow(position:) メソッドで、現在のリスクレベルや飲酒量を矢印とラベルで表示します。

コードの使い方

VerticalProgressBar を画面に追加し、飲酒量をリアルタイムで更新します。

import UIKit


class ViewController: UIViewController {
    
    @IBOutlet weak var totalLabel: UILabel!
    
    private var totalPureAlcoholContent: Double = 0.0
    private let progressBar = VerticalProgressBar()

    override func viewDidLoad() {
        super.viewDidLoad()

        // プログレスバーをプログラムで追加
        progressBar.frame = CGRect(x: 150, y: 100, width: 30, height: 300)
        progressBar.maxAlcoholContent = 60.0 // 最大値を設定
        view.addSubview(progressBar)
        
        ...
}

ボタンによる進捗の更新

ボタンを押すたびにランダムなアルコール量を追加し、プログレスバーとラベルを更新します。

@IBAction func onTappedClick(_ sender: Any) {
    let randomAlcohol = Double.random(in: 2.0...10.0)
    totalPureAlcoholContent += randomAlcohol
    
    progressBar.progress = totalPureAlcoholContent
    totalLabel.text = String(format: "%.1f g", totalPureAlcoholContent)
}

リセット機能

飲酒量とプログレスバーをリセットします。

@IBAction func onTappedReset(_ sender: UIButton) {
    totalPureAlcoholContent = 0.0
    progressBar.reset()
    totalLabel.text = String(format: "%.1f g", totalPureAlcoholContent)
}

GitHubリポジトリでサンプルコードを公開しています:

リポジトリ内の AlcoholMeterSampleApp ターゲットをビルドすると動作を確認できます。

【開発7日目】感情・日記の入力画面をつくる

断酒してから7日目が経ちました。1週間の断酒に成功です!自分を褒めたいです。いや、むしろ毎日褒めています。

体調もマインドもすこぶる快調で、日々の充実感がまるで別物です。「お酒は本当に必要ないんだな」と心から思えるようになってきました。

飲酒していた頃は、夜中に喉が渇いて途中で目が覚めたり、睡眠の質が悪くて長時間寝てしまうことがよくありました。特に週末になると、仕事から解放された反動でたくさん飲んでしまい、せっかくの休日を二日酔いで無駄にしていました。振り返ると、人生の時間をかなり無駄にしていた気がします。

若い頃ならまだ時間や体力に余裕がありましたが、年齢を重ねるにつれて「このままで良いのか」と焦りを感じるようになりました。そんな中、断酒に挑戦しているわけですが、昨日はなんと山登りに行ってきました!以前からぼんやりと「やりたいな」と思っていたことの一つです。高尾山に行ってきたのですが、誰でも登れる山とはいえ、自然と触れ合えて大満足でした。その様子を動画に収めたので、ぜひご覧ください。

山にはたくさんの人がいましたが、それでも自然と触れ合うことで心が癒されました。「これが私のやりたかったことなんだ」と実感できました。これからの人生、もっと自然と触れ合っていきたいと強く思いました。この気持ちになれたのも、断酒を続けているおかげです。ありがとうございます!

感情・日記の入力画面を作る

前置きが長くなりましたが、今回は感情や日記を記録する入力画面のUIを作ってみます。断酒の日々の思いを記録することは、自己成長に繋がると考えています。その日その日の達成感を味わえますし、記録を積み重ねることで自信にもなります。また、万が一飲酒してしまった場合でも、過去の記録を見返すことで「あの時の気持ちをもう一度味わいたい」と思い、断酒を再開するきっかけになるはずです。

そんな理由から、この機能をアプリに実装しようと考えました。次の動画のような動きを作っていきます。

感情・日記の入力画面

感情を5段階評価する enum の準備

感情を選択するために、5段階の評価を表現できる enum を用意します。各ケースには適切なラベルを設定しており、UIで簡単に使える形にしています。

enum EmotionLevel: Int, CaseIterable {
    case notSelected = 0 // 未選択
    case worst = 1       // 最悪
    case bad = 2         // 悪い
    case neutral = 3     // 普通
    case good = 4        // 良い
    case best = 5        // 最高
    
    // ラベルを定義
    var label: String {
        switch self {
        case .notSelected: return "未選択"
        case .worst: return "最悪"
        case .bad: return "悪い"
        case .neutral: return "普通"
        case .good: return "良い"
        case .best: return "最高"
        }
    }
}

この enum を利用することで、感情の状態をコード内で簡潔かつ安全に扱えます。

丸ボタン

感情の選択を視覚的に行えるよう、丸いボタンを作成します。 UIView を継承したカスタムクラス CircleButtonView を用いて、ボタンとラベルをセットで表示します。

import UIKit

class CircleButtonView: UIView {
    
    private let button: UIButton = UIButton(type: .system)
    private let label: UILabel = UILabel()
    
    // ボタンがタップされたときのコールバック
    var onTap: (() -> Void)?
    
    // 初期化
    init(icon: UIImage?, labelText: String) {
        super.init(frame: .zero)
        
        // ボタンの設定
        button.setImage(icon, for: .normal)
        button.tintColor = .white
        button.backgroundColor = .lightGray
        button.layer.masksToBounds = true
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
        
        // ラベルの設定
        label.text = labelText
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = .darkGray
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        
        // サブビューを追加
        addSubview(button)
        addSubview(label)
        
        // レイアウトを設定
        NSLayoutConstraint.activate([
            button.widthAnchor.constraint(equalTo: self.widthAnchor), // 親ビューの幅にフィット
            button.heightAnchor.constraint(equalTo: button.widthAnchor), // 正方形にする
            button.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            button.topAnchor.constraint(equalTo: self.topAnchor),
            
            label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 8),
            label.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            label.bottomAnchor.constraint(equalTo: self.bottomAnchor)
        ])
    }
    
    @objc private func buttonTapped() {
        onTap?() // コールバックを実行
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        button.layer.cornerRadius = button.bounds.width / 2 // ボタンを円形にする
    }
    
    // ボタンの背景色を変更するメソッド
    func updateButtonBackgroundColor(_ color: UIColor) {
        button.backgroundColor = color
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

感情選択ボタンのセットアップ

感情選択ボタンとテキスト入力フィールドを組み合わせた画面を構築します。 ViewController 内で感情を選択するロジックや、メモ入力用の UITextView をセットアップします。

感情の選択は丸ボタンを並べたスタックビューで表現します。選択状態に応じてボタンの背景色が変わる仕組みを実装しています。

import UIKit


class ViewController: UIViewController {
    
    private var selectedEmotion: EmotionLevel = .notSelected {
        didSet {
            updateButtonStates()
        }
    }
    
    private var circleButtonViews: [CircleButtonView] = []
    private var textView: UITextView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTextView()
        setupEmotionButtons()
        setupDismissKeyboardGesture()

    }
    
    private func setupTextView() {
        textView = UITextView()
        textView.translatesAutoresizingMaskIntoConstraints = false
        textView.layer.borderWidth = 1
        textView.layer.borderColor = UIColor.lightGray.cgColor
        textView.layer.cornerRadius = 8
        textView.font = UIFont.systemFont(ofSize: 16)
        textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
        textView.isScrollEnabled = true
        textView.text = "感想やメモを入力してください..."
        textView.textColor = .lightGray
        
        // プレースホルダーのような動作を実現
        textView.delegate = self
        
        view.addSubview(textView)
        
        NSLayoutConstraint.activate([
            textView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
            textView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
            textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
            textView.heightAnchor.constraint(equalToConstant: 200) // 高さを固定
        ])
    }
    
    private func setupEmotionButtons() {
        let emotions = EmotionLevel.allCases.filter { $0 != .notSelected }
        
        // スタックビューで配置
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.alignment = .center
        stackView.distribution = .fillEqually
        stackView.spacing = 16
        stackView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stackView)
        
        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 10),
            stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
            stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10)
        ])
        
        for emotion in emotions {
            let icon = UIImage(systemName: "face.smiling") // 任意のアイコン
            let circleButtonView = CircleButtonView(icon: icon, labelText: emotion.label)
            
            circleButtonView.onTap = { [weak self] in
                self?.selectedEmotion = emotion
            }
            
            stackView.addArrangedSubview(circleButtonView)
            circleButtonViews.append(circleButtonView)
        }
        
        updateButtonStates()
    }
    
    private func updateButtonStates() {
        for (index, buttonView) in circleButtonViews.enumerated() {
            let emotion = EmotionLevel(rawValue: index + 1) // `notSelected`を除外した分ずらす
            let isSelected = emotion == selectedEmotion
            buttonView.updateButtonBackgroundColor(isSelected ? .systemPink : .lightGray) // ボタンの背景色を更新
        }
    }
    
    private func setupDismissKeyboardGesture() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        view.addGestureRecognizer(tapGesture)
    }
    
    @objc private func dismissKeyboard() {
        view.endEditing(true) // キーボードを閉じる
    }
}

キーボードを閉じるジェスチャーの設定について

iOSアプリでは、UITextViewUITextField に文字を入力した後、画面のどこかをタップしてキーボードを閉じたいというケースがよくあります。しかし、デフォルトではタップしてもキーボードが閉じないため、明示的にその処理を実装する必要があります。

setupDismissKeyboardGesture メソッドで、UITapGestureRecognizer を使ってタップを検知するジェスチャーを作成し、画面全体 (view) に追加しています。これでタップジェスチャーを利用してキーボードを閉じる仕組みが実現できます。

view.addGestureRecognizer(tapGesture) のジェスチャーをビューに追加することで、タップイベントを検知できるようにしています。タップジェスチャーによって呼び出される dismissKeyboard メソッドの中では、view.endEditing(true) を使用して現在のファーストレスポンダー(=入力中の UITextViewUITextField)の編集状態を終了します。これにより、キーボードが非表示になります。true を指定すると、すべてのファーストレスポンダーを終了対象とします。

コードを試してみる

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。

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

【開発9日目】Realmでデータの永続化

断酒してから9日が経過しました!お酒のない生活にも少しずつ慣れ、空いた時間で新しいことに挑戦する余裕も生まれてきました。その一環として、アプリ開発をコツコツ進めております。健康志向が芽生えてきたこともあり、ジョギングを趣味として始めたのも最近の大きな変化です。普段は30分ほど、3km〜5kmを目安に走る程度ですが、ある日妙に調子が良く、40km近く走る暴挙に出た結果、膝を痛めるという大失態を犯しました。その後1ヶ月ほど苦しみましたが、ようやく膝も治り、再びジョギング生活が戻ってきました。

そんな中、新たな相棒としてランニングシューズを購入しました。つい先日まではAmazonで適当に買った3000円のシューズを履いていましたが、これが微妙に大きく、走りづらさを感じる原因だったようです。先日、 高尾山に登った帰り に新宿のL-Breathに立ち寄り、アシックスのシューズを購入することに。

驚いたのは、店舗に備わっている最新鋭のレーザースキャン。足のサイズを正確に計測し、自分に合った靴を即座に提案してくれました。結果、自分が想定していたよりも足のサイズが小さいことが発覚!一方で横幅が広いため、サイズ選びに苦戦していた理由がようやくわかりました。新しいシューズは「これだ!」と思える履き心地で、地面からの衝撃をしっかり吸収してくれそうです。これなら膝を痛める心配も少なそう。トレンドの厚底シューズの恩恵に、さっそく期待が高まります。

ここで改めて感じたのは、「適切なツール選び」の重要性です。シューズ一つでこれだけ快適さが変わるのなら、アプリ開発においても適切なツールを選ぶことで効率や完成度に大きな差が出るはずです。

さて、今回進めている断酒サポートアプリの開発でも、この「ツール選び」がポイントとなりました。iOSアプリ開発ではデータの永続化が欠かせませんが、どの方法を使うべきか迷うところです。候補として挙がるのは以下の通り:

今回は、セットアップが容易で直感的に使える Realm を選びました。ここからは、このRealmを使ってアプリのデータを永続化する方法について紹介していきます。

Realmの概要

Realmはモバイル向けのデータベースで、以下の特徴があります:

CocoaPodsを使用したRealmのインストール

以下のようにPodfileを編集してRealmをインストールします。

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '13.0'

use_modular_headers!

target 'RealmSampleApp' do
  use_frameworks!
  
  pod 'RealmSwift'
end

その後、以下のコマンドを実行します:

pod install

これでプロジェクトにRealmが導入されます。

UIの実装

以下のようなUIを準備します。

class ViewController: UIViewController {

    // UIコンポーネント
    let datePicker: UIDatePicker = {
        let picker = UIDatePicker()
        picker.datePickerMode = .date
        picker.translatesAutoresizingMaskIntoConstraints = false
        return picker
    }()
    
    let textView: UITextView = {
        let textView = UITextView()
        textView.layer.borderColor = UIColor.lightGray.cgColor
        textView.layer.borderWidth = 1.0
        textView.layer.cornerRadius = 5.0
        textView.translatesAutoresizingMaskIntoConstraints = false
        return textView
    }()
    
    let saveButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("保存", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    let loadButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("読み込み", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    let resultLabel: UILabel = {
        let label = UILabel()
        label.text = "日記がここに表示されます"
        label.numberOfLines = 0
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    

    let idTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "削除したいIDを入力"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        return textField
    }()
    
    let deleteButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("削除", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupUI()
        
        saveButton.addTarget(self, action: #selector(saveDiary), for: .touchUpInside)
        loadButton.addTarget(self, action: #selector(loadDiary), for: .touchUpInside)
        deleteButton.addTarget(self, action: #selector(deleteDiary), for: .touchUpInside)

    }
    
    // UIのレイアウト設定
    func setupUI() {
        view.addSubview(datePicker)
        view.addSubview(textView)
        view.addSubview(saveButton)
        view.addSubview(loadButton)
        view.addSubview(resultLabel)
        view.addSubview(idTextField)
        view.addSubview(deleteButton)
        

        NSLayoutConstraint.activate([
            datePicker.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            datePicker.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            
            textView.topAnchor.constraint(equalTo: datePicker.bottomAnchor, constant: 20),
            textView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            textView.widthAnchor.constraint(equalToConstant: 300),
            textView.heightAnchor.constraint(equalToConstant: 150),
            
            saveButton.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 20),
            saveButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: -70),
            
            loadButton.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 20),
            loadButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 70),
            
            resultLabel.topAnchor.constraint(equalTo: saveButton.bottomAnchor, constant: 30),
            resultLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            resultLabel.widthAnchor.constraint(equalToConstant: 300),
            
            idTextField.topAnchor.constraint(equalTo: resultLabel.bottomAnchor, constant: 20),
            idTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            idTextField.widthAnchor.constraint(equalToConstant: 300),
            
            deleteButton.topAnchor.constraint(equalTo: idTextField.bottomAnchor, constant: 10),
            deleteButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)

        ])
    }
    
    // 日記を保存する処理
    @objc func saveDiary() {
        let realm = try! Realm()
        let selectedDate = datePicker.date
        
        // プライマリキーがYYYYMMDD形式になるように設定
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyyMMdd" // 固定フォーマット
        let id = dateFormatter.string(from: selectedDate)
        print(id) // 例: 20241129
        
        // データ作成または更新
        let entry = DiaryEntry()
        entry.id = id
        entry.date = selectedDate
        entry.content = textView.text
        
        try! realm.write {
            realm.add(entry, update: .modified)
        }
        
        resultLabel.text = "日記を保存しました!"
        textView.text = ""
    }

    ...
}

UI説明

このアプリのUIは、日記を保存、読み込み、削除するためのシンプルな構成になっています。以下、それぞれの要素と役割について説明します:

日付選択(UIDatePicker)

日記の対象日を指定するために使用します。デフォルトでは今日の日付が選択されています。

テキスト入力エリア(UITextView)

日記の内容を入力するためのエリアです。入力後、「保存」ボタンを押すことでRealmに保存されます。

保存ボタン(UIButton)

入力した日記をRealmに保存します。同じ日付がすでに保存されている場合は上書きされます。

読み込みボタン(UIButton)

選択した日付の日記をRealmから読み込み、結果を表示します。

結果表示ラベル(UILabel)

読み込まれた日記や処理の結果を表示します。日記が存在しない場合は「指定の日付に日記はありません」と表示されます。

削除用ID入力フィールド(UITextField)

削除したい日記のIDを手動で入力するためのフィールドです。

削除ボタン(UIButton)

入力されたIDの日記をRealmから削除します。該当する日記がない場合はエラーメッセージを表示します。

モデル定義

以下は、日記アプリを例にしたデータ操作の実装コードです。 まず、DiaryEntryモデルを作成します。

import RealmSwift

class DiaryEntry: Object {
    @Persisted(primaryKey: true) var id: String // プライマリキー
    @Persisted var date: Date
    @Persisted var content: String
}

UIのセットアップと機能実装

以下のコードを参考に、ViewControllerにUIと機能を実装します。

日記の保存

@objc func saveDiary() {
    let realm = try! Realm()
    let selectedDate = datePicker.date

    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyyMMdd"
    let id = dateFormatter.string(from: selectedDate)

    let entry = DiaryEntry()
    entry.id = id
    entry.date = selectedDate
    entry.content = textView.text

    try! realm.write {
        realm.add(entry, update: .modified)
    }

    resultLabel.text = "日記を保存しました!"
    textView.text = ""
}

日記の読み込み

@objc func loadDiary() {
    let realm = try! Realm()
    let selectedDate = datePicker.date

    let startOfDay = Calendar.current.startOfDay(for: selectedDate)
    let endOfDay = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!

    let predicate = NSPredicate(format: "date >= %@ AND date < %@", startOfDay as NSDate, endOfDay as NSDate)
    let entry = realm.objects(DiaryEntry.self).filter(predicate).first

    if let entry = entry {
        resultLabel.text = "日記: \(entry.content)"
        idTextField.text = entry.id
    } else {
        resultLabel.text = "指定の日付に日記はありません"
    }
}

日記の削除

@objc func deleteDiary() {
    let realm = try! Realm()
    guard let id = idTextField.text, !id.isEmpty else {
        resultLabel.text = "IDを入力してください"
        return
    }

    if let entry = realm.object(ofType: DiaryEntry.self, forPrimaryKey: id) {
        try! realm.write {
            realm.delete(entry)
        }
        resultLabel.text = "ID: \(id) の日記を削除しました"
        idTextField.text = ""
    } else {
        resultLabel.text = "ID: \(id) の日記は存在しません"
    }
}

マイグレーション方法

アプリのリリース後にデータモデルを変更した場合、マイグレーションが必要です。以下はマイグレーションの設定例です。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let config = Realm.Configuration(
        schemaVersion: 2,
        migrationBlock: { migration, oldSchemaVersion in
            if oldSchemaVersion < 2 {
                // 必要なマイグレーション処理
            }
        }
    )

    Realm.Configuration.defaultConfiguration = config
    return true
}

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。

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

【開発15日目】紙吹雪アニメーション

断酒を始めてから、今日でちょうど半月が経ちました!おめでとうございます!ありがとうございます!

いやー、この二週間、本当に長かったです。飲酒していた頃は、日々があっという間に過ぎていく感覚でしたが、断酒を始めてからは毎日が充実していて、「まだ2週間しか経ってないの?」と驚くほどです。

断酒後、まず変化を感じたのは「起きている時間の長さ」です。飲酒時と比べて睡眠の質が良くなり、短い睡眠でも十分に疲れが取れるようになりました。その結果、夜の時間を有効に使えるようになり、読書をするのが新しい習慣に。読書を通じて新しい情報をインプットしたり、擬似体験を楽しんだりしていることが、時間の充実感につながっているのかもしれません。

もちろん、まだお酒のことを思い出す瞬間はありますが、「呑みたい」という気持ちはほとんどなく、無理に我慢しているわけでもありません。たまに「今呑んだらどうなるんだろう?」と想像することはありますが、またあの苦痛な生活に戻るのかと思うと、今の方がずっと満たされていると実感できます。

最近読んでいる 「そろそろ、お酒をやめようかな」 という本では、飲酒が脳に与える影響について説明されていました。飲酒はドーパミンの分泌を促進させ、楽しさを感じさせますが、その反動で脳が過剰なドーパミンを抑制したり感じにくくさせるようです(ダウンレギュレーション)。つまり、シラフの時間を楽しめなくなったり、鬱っぽくなったりするのだとか。私も経験あるあるなのでよくわかります。怖いですね。一方で断酒を始めると、脳は回復し始め、物事を楽しめるようになります。実際に今の私がまさにそうです。この変化を実感できるのは、本当に嬉しいことです。

日々に感謝し、小さな幸せを見つけることも、断酒生活を充実させるコツだと感じます。例えば、最近は毎週山登りをしていますが、登山の途中で綺麗な水を飲むという行為だけでも、世界的に見ればとても恵まれたことだと気づきます。日本の豊かな自然の中で過ごせることも、考えればとても贅沢で幸せなことです。

「足るを知る」という言葉がありますが、今の私はまさにその境地です。特別な変化がなくても、今あるものに目を向ければ、それだけで毎日が幸せに感じられるのではないでしょうか。

さて、今回の「断酒iOSアプリ制作」では、紙吹雪のアニメーションを制作してみました。お酒を飲まなかった日は、このアニメーションを表示させて少しでも達成感を演出できればと思います。

紙吹雪アニメーション1

ChatGPTに紙吹雪アニメーションを相談すると、意外にも簡単に実現できることがわかりました。

デザイン調整も何もない状態ですが、こんな感じでランダムな紙吹雪がループされます。

ソースコード

以下がソースコードです。あらかじめ、三角・四角・丸の画像を用意しておく必要があります。

    func addConfettiAnimation() {
        let emitterLayer = CAEmitterLayer()
        emitterLayer.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
        emitterLayer.emitterShape = .line
        emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)

        let colors: [UIColor] = [.red, .green, .blue, .yellow, .purple, .orange]
        let images = ["circle", "square", "triangle"] // カスタム画像名

        var cells: [CAEmitterCell] = []

        for color in colors {
            for imageName in images {
                let cell = CAEmitterCell()
                cell.contents = UIImage(named: imageName)?.cgImage
                cell.birthRate = 6
                cell.lifetime = 10.0
                cell.velocity = 150
                cell.velocityRange = 50
                cell.emissionLongitude = .pi
                cell.emissionRange = .pi / 4
                cell.spin = 4
                cell.spinRange = 2
                cell.scale = 0.5
                cell.scaleRange = 0.3
                cell.color = color.cgColor
                cells.append(cell)
            }
        }

        emitterLayer.emitterCells = cells
        view.layer.addSublayer(emitterLayer)
    }

以下のようにして呼び出します。

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

紙吹雪アニメーション2

パラメーターを変えてデザインを調整していきます。

ソースコード

    func addConfettiAnimation() {
        let emitterLayer = CAEmitterLayer()
        emitterLayer.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
        emitterLayer.emitterShape = .line
        emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)

        // カラフルでまとまりのある色を指定
        let colors: [UIColor] = [
            UIColor(red: 1.0, green: 0.4, blue: 0.4, alpha: 1.0), // Bright Red
            UIColor(red: 1.0, green: 0.8, blue: 0.2, alpha: 1.0), // Bright Yellow
            UIColor(red: 0.3, green: 0.8, blue: 1.0, alpha: 1.0), // Bright Blue
            UIColor(red: 0.6, green: 1.0, blue: 0.4, alpha: 1.0), // Light Green
            UIColor(red: 1.0, green: 0.6, blue: 1.0, alpha: 1.0), // Pink
            UIColor(red: 1.0, green: 0.5, blue: 0.2, alpha: 1.0)  // Orange
        ]
        
        // 細かい形状(サークルや四角など)
        let shapes = ["circle", "square", "triangle"]

        var cells: [CAEmitterCell] = []

        for color in colors {
            for shape in shapes {
                let cell = CAEmitterCell()
                cell.contents = UIImage(named: shape)?.cgImage
                cell.birthRate = 6
                cell.lifetime = 10.0
                cell.velocity = 150
                cell.velocityRange = 50
                cell.emissionLongitude = .pi
                cell.emissionRange = .pi / 4
                cell.spin = 2
                cell.spinRange = 2
                cell.scale = 0.3 // サイズを小さめに
                cell.scaleRange = 0.3
                cell.color = color.cgColor
                cells.append(cell)
            }
        }

        emitterLayer.emitterCells = cells
        view.layer.addSublayer(emitterLayer)
    }

紙吹雪アニメーション3

画像素材を、丸・星・ダイアモンド型に変更すると可愛らしい雰囲気になりました。

ソースコード

    func addConfettiAnimation() {
        let emitterLayer = CAEmitterLayer()
        emitterLayer.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
        emitterLayer.emitterShape = .line
        emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)

        // 達成感を感じさせる華やかな色
        let colors: [UIColor] = [
            UIColor(red: 1.0, green: 0.85, blue: 0.35, alpha: 1.0), // Gold
            UIColor(red: 0.85, green: 0.95, blue: 1.0, alpha: 1.0), // Light Blue
            UIColor(red: 0.5, green: 0.8, blue: 0.3, alpha: 1.0),  // Green
            UIColor(red: 1.0, green: 0.6, blue: 0.6, alpha: 1.0),  // Soft Red
            UIColor(red: 0.9, green: 0.75, blue: 1.0, alpha: 1.0), // Lavender
            UIColor(red: 1.0, green: 1.0, blue: 0.8, alpha: 1.0)   // Champagne
        ]
        
        // 細かい形状(サークルや四角など)
        let shapes = ["circle", "star", "diamond"]

        var cells: [CAEmitterCell] = []

        for color in colors {
            for shape in shapes {
                let cell = CAEmitterCell()
                cell.contents = UIImage(named: shape)?.cgImage
                cell.birthRate = 6
                cell.lifetime = 10.0
                cell.velocity = 150
                cell.velocityRange = 50
                cell.emissionLongitude = .pi
                cell.emissionRange = .pi / 4
                cell.spin = 2
                cell.spinRange = 2
                cell.scale = 0.3 // サイズを小さめに
                cell.scaleRange = 0.3
                cell.color = color.cgColor
                cells.append(cell)
            }
        }

        emitterLayer.emitterCells = cells
        view.layer.addSublayer(emitterLayer)
    }

紙吹雪アニメーション4

さらに少し色を調整して、完成させました。

ソースコード

    func addConfettiAnimation() {
        let emitterLayer = CAEmitterLayer()
        emitterLayer.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
        emitterLayer.emitterShape = .line
        emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)

        // 鮮やかでカラフルな色合い
        let colors: [UIColor] = [
            UIColor(red: 1.0, green: 0.35, blue: 0.35, alpha: 1.0), // 赤
            UIColor(red: 1.0, green: 0.8, blue: 0.2, alpha: 1.0),  // 黄色
            UIColor(red: 0.3, green: 0.8, blue: 1.0, alpha: 1.0),  // 水色
            UIColor(red: 0.5, green: 0.9, blue: 0.4, alpha: 1.0),  // 緑
            UIColor(red: 1.0, green: 0.6, blue: 0.9, alpha: 1.0),  // ピンク
            UIColor(red: 0.9, green: 0.7, blue: 1.0, alpha: 1.0)   // 紫
        ]
        
        // 細かい形状(サークルや四角など)
        let shapes = ["circle", "star", "diamond"]

        var cells: [CAEmitterCell] = []

        for color in colors {
            for shape in shapes {
                let cell = CAEmitterCell()
                cell.contents = UIImage(named: shape)?.cgImage
                cell.birthRate = 6
                cell.lifetime = 10.0
                cell.velocity = 150
                cell.velocityRange = 50
                cell.emissionLongitude = .pi
                cell.emissionRange = .pi / 4
                cell.spin = 2
                cell.spinRange = 2
                cell.scale = 0.3 // サイズを小さめに
                cell.scaleRange = 0.3
                cell.color = color.cgColor
                cells.append(cell)
            }
        }

        emitterLayer.emitterCells = cells
        view.layer.addSublayer(emitterLayer)
    }

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。 - https://github.com/aragig/ios_sample_dansyu

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

【開発23日目】カスタムプリセット画面

断酒を始めてから3週間が経ちました。振り返ってみると、たったの3週間とは思えないほど長く感じます。断酒を本当に習慣化するには、最低でも90日以上の継続が必要だと聞きます。そう考えると、少し不安になることもありますが、一日一日を積み重ねていくしかありませんね。この先も、断酒を続けていこうと思います。

さて、最近は断酒アプリ制作がなかなか進んでおりません。その理由は、以前お酒を飲んでいた時間が空いたことで、やりたいことが増えたからです。読書や山登り、そして最近では断捨離にも力を入れています。

この3週間で 『僕たちに、もうモノは必要ない。』 という本を2回も読んでしまうほど、とくに断捨離やミニマリズムには惹かれています。このジャンルの本の中では、一番納得感があり、共感できる内容でした。

断捨離中に処分した金属ゴミ

前回の記事でも触れましたが、「足るを知る」という考え方に深く共感しています。本書では、便利なものをあえて手放し、少し不便を受け入れることで得られる豊かさについて書かれていました。例えば、著者がタオルをやめて手拭いに切り替えた話がありました。手拭いはタオルほどの吸水力はありませんが、乾きが早いというメリットがあります。そして、たまにタオルを手にした時、タオルのふんわりさに驚くほどのありがたさ、うれしさを感じるのだとか。確かにいつもタオルを使っていると、ありがたみを感じる機会はありませんよね。また、便利なものばかりを求めると、物は増え、掃除が大変になったり、買い替えの手間が増えたりして、結果として物に振り回されることになるのだと感じます。

「すでに事足りているのでは?」と自分に問いかける時間が、私にとっての断捨離なのかもしれません。これって、キャンプのノリで生きてる感じでなんか楽しそうです。バックパックに必要最低限の荷物を詰めて出かけるキャンプでは、当然リュックに入る荷物には限度がありますから。何かを諦めて、不便さを引き受けなければなりません。しばらく忘れてました、この感覚。少し不便になっても良いから、代用できるものは手放すことを試しています。さすがにタオルは使ってますが、登山などで便利なドライタオルを数枚発注しました(※また物が増えてしまっていますが、タオルの代替実験ということで良しとします!)。

処分したいものはすでに決まっているので、年内にどこまで進められるかですね。どうしてもゴミに出すのはもったいないと思うモノは、メルカリなどを活用して処分しているのですが、これもなかなか骨の折れる作業でして。少しずつではありますが、物に感謝しながら「どなたかに大切にされますように」と願いを込めて手放しています。

さて、断捨離のことを書き始めると止まらなくなりそうなので、そろそろこの辺で。今回の断酒アプリ制作では、飲酒量のカスタムプリセット画面を作成していきます。そうそう、私にとっては断酒という名の断捨離ほどの大きなものはありませんでしたね!

金属ゴミの重さはなんと、17kgも!こんなゴミがまだまだある。。

アプリの全体像

このアプリでは、飲酒量を記録するプリセットを管理する仕組みを提供しています。メイン画面でプリセットを一覧管理し、詳細画面でプリセットを作成・編集する流れを採用しています。シンプルながら拡張性の高い設計となっています。

MainViewController.swift: メイン画面の管理

このクラスは、アプリのメイン画面を担当します。ユーザーがプリセットをリスト形式で閲覧・編集するためのインターフェースを提供します。

注目ポイント:

ソースコード

//
//  MainViewController.swift
//  AlcoholPresetSampleApp
//
//  Created by Toshihiko Arai on 2024/12/12.
//

import UIKit

class MainViewController: UIViewController {

    var tableView: UITableView!
    var presets: [AlcoholPreset] = [
        AlcoholPreset(name: "ビール500缶", alcoholPercentage: 5.0, volume: 500),
        AlcoholPreset(name: "赤ワイン1杯", alcoholPercentage: 13.0, volume: 120),
        AlcoholPreset(name: "赤ワイン1本", alcoholPercentage: 13.0, volume: 720),
        AlcoholPreset(name: "日本酒1合", alcoholPercentage: 15.0, volume: 180),
        AlcoholPreset(name: "ウイスキーダブル1杯", alcoholPercentage: 43.0, volume: 60),
        AlcoholPreset(name: "ストロング 9% 500ml", alcoholPercentage: 9.0, volume: 500),
        AlcoholPreset(name: "缶チューハイ 7% 500ml", alcoholPercentage: 7.0, volume: 500),
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        setupNavigationBar()
    }

    private func setupTableView() {
        tableView = UITableView(frame: view.bounds, style: .plain)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(CustomTableViewCell.self, forCellReuseIdentifier: "CustomCell")
        tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(tableView)
    }

    private func setupNavigationBar() {
        let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped))
        let editButton = UIBarButtonItem(title: "並べ替え", style: .plain, target: self, action: #selector(toggleEditMode))
        navigationItem.rightBarButtonItems = [addButton, editButton]
    }

    private func showDetailViewController(for preset: AlcoholPreset?, index: Int?) {
        let detailVC = DetailViewController()
        detailVC.preset = preset
        detailVC.index = index
        detailVC.delegate = self
        navigationController?.pushViewController(detailVC, animated: true)
    }

    @objc private func addButtonTapped() {
        showDetailViewController(for: nil, index: nil)
    }

    @objc private func toggleEditMode() {
        tableView.setEditing(!tableView.isEditing, animated: true)
        navigationItem.rightBarButtonItems?[1].title = tableView.isEditing ? "完了" : "並べ替え"
    }
}

extension MainViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presets.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as? CustomTableViewCell else {
            return UITableViewCell()
        }
        let preset = presets[indexPath.row]
        cell.configure(name: preset.name, alcoholPercentage: preset.alcoholPercentage, volume: preset.volume, pureAlcohol: preset.pureAlcohol)
        return cell
    }

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            presets.remove(at: indexPath.row)
            tableView.deleteRows(at: [indexPath], with: .automatic)
        }
    }
}

extension MainViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let preset = presets[indexPath.row]
        showDetailViewController(for: preset, index: indexPath.row)
    }

    func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
        return true
    }

    func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        let movedPreset = presets.remove(at: sourceIndexPath.row)
        presets.insert(movedPreset, at: destinationIndexPath.row)
    }
}

extension MainViewController: DetailViewControllerDelegate {
    func didSavePreset(_ preset: AlcoholPreset, at index: Int?) {
        if let index = index {
            presets[index] = preset
        } else {
            presets.append(preset)
        }
        tableView.reloadData()
    }
}

DetailViewController.swift: プリセットの詳細編集画面

このクラスは、アルコールプリセットの追加または編集を行う詳細画面です。

注目ポイント:

//
//  DetailViewController.swift
//  AlcoholPresetSampleApp
//
//  Created by Toshihiko Arai on 2024/12/12.
//

import UIKit

protocol DetailViewControllerDelegate: AnyObject {
    func didSavePreset(_ preset: AlcoholPreset, at index: Int?)
}


class DetailViewController: UIViewController {
    // UIコンポーネント
    private let nameLabel = UILabel()
    private let nameTextField = UITextField()
    private let alcoholLabel = UILabel()
    private let alcoholPercentageTextField = UITextField()
    private let alcoholUnitLabel = UILabel()
    private let volumeLabel = UILabel()
    private let volumeTextField = UITextField()
    private let volumeUnitLabel = UILabel()
    private let pureAlcoholLabel = UILabel()
    private let pureAlcoholTextField = UITextField()
    private let pureAlcoholUnitLabel = UILabel()
    private let saveButton = UIButton(type: .system)

    var preset: AlcoholPreset?
    var index: Int?
    weak var delegate: DetailViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupUI()
        populateData()
        updatePureAlcoholField()

        // テキストフィールドの値変更を監視
        alcoholPercentageTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
        volumeTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
    }

    private func setupUI() {
        // ラベルの共通設定
        func setupLabel(_ label: UILabel, text: String) {
            label.text = text
            label.textAlignment = .right
            label.translatesAutoresizingMaskIntoConstraints = false
        }

        // 各ラベルの設定
        setupLabel(nameLabel, text: "名前:")
        setupLabel(alcoholLabel, text: "アルコール度数:")
        setupLabel(volumeLabel, text: "量:")
        setupLabel(pureAlcoholLabel, text: "純アルコール量:")

        // 各テキストフィールドと単位ラベルの設定
        nameTextField.placeholder = "酒の名前"
        nameTextField.borderStyle = .roundedRect
        nameTextField.translatesAutoresizingMaskIntoConstraints = false

        alcoholPercentageTextField.placeholder = "例: 5.0"
        alcoholPercentageTextField.keyboardType = .decimalPad
        alcoholPercentageTextField.borderStyle = .roundedRect
        alcoholPercentageTextField.translatesAutoresizingMaskIntoConstraints = false

        alcoholUnitLabel.text = "%"
        alcoholUnitLabel.translatesAutoresizingMaskIntoConstraints = false

        volumeTextField.placeholder = "例: 500"
        volumeTextField.keyboardType = .decimalPad
        volumeTextField.borderStyle = .roundedRect
        volumeTextField.translatesAutoresizingMaskIntoConstraints = false

        volumeUnitLabel.text = "ml"
        volumeUnitLabel.translatesAutoresizingMaskIntoConstraints = false

        pureAlcoholTextField.placeholder = ""
        pureAlcoholTextField.keyboardType = .decimalPad
        pureAlcoholTextField.borderStyle = .roundedRect
        pureAlcoholTextField.isEnabled = false
        pureAlcoholTextField.translatesAutoresizingMaskIntoConstraints = false

        pureAlcoholUnitLabel.text = "ml"
        pureAlcoholUnitLabel.translatesAutoresizingMaskIntoConstraints = false

        saveButton.setTitle("保存", for: .normal)
        saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
        saveButton.translatesAutoresizingMaskIntoConstraints = false

        // ビューに追加
        view.addSubview(nameLabel)
        view.addSubview(nameTextField)
        view.addSubview(alcoholLabel)
        view.addSubview(alcoholPercentageTextField)
        view.addSubview(alcoholUnitLabel)
        view.addSubview(volumeLabel)
        view.addSubview(volumeTextField)
        view.addSubview(volumeUnitLabel)
        view.addSubview(pureAlcoholLabel)
        view.addSubview(pureAlcoholTextField)
        view.addSubview(pureAlcoholUnitLabel)
        view.addSubview(saveButton)

        // 定数設定
        let labelWidth: CGFloat = 120
        let fieldSpacing: CGFloat = 10

        // Auto Layout
        NSLayoutConstraint.activate([
            // 名前ラベルとフィールド
            nameLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            nameLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            nameLabel.widthAnchor.constraint(equalToConstant: labelWidth),

            nameTextField.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor),
            nameTextField.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: fieldSpacing),
            nameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),

            // アルコール度数ラベルとフィールド
            alcoholLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20),
            alcoholLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            alcoholLabel.widthAnchor.constraint(equalToConstant: labelWidth),

            alcoholPercentageTextField.centerYAnchor.constraint(equalTo: alcoholLabel.centerYAnchor),
            alcoholPercentageTextField.leadingAnchor.constraint(equalTo: alcoholLabel.trailingAnchor, constant: fieldSpacing),
            alcoholPercentageTextField.widthAnchor.constraint(equalToConstant: 100),

            alcoholUnitLabel.centerYAnchor.constraint(equalTo: alcoholPercentageTextField.centerYAnchor),
            alcoholUnitLabel.leadingAnchor.constraint(equalTo: alcoholPercentageTextField.trailingAnchor, constant: fieldSpacing),

            // 量ラベルとフィールド
            volumeLabel.topAnchor.constraint(equalTo: alcoholLabel.bottomAnchor, constant: 20),
            volumeLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            volumeLabel.widthAnchor.constraint(equalToConstant: labelWidth),

            volumeTextField.centerYAnchor.constraint(equalTo: volumeLabel.centerYAnchor),
            volumeTextField.leadingAnchor.constraint(equalTo: volumeLabel.trailingAnchor, constant: fieldSpacing),
            volumeTextField.widthAnchor.constraint(equalToConstant: 100),

            volumeUnitLabel.centerYAnchor.constraint(equalTo: volumeTextField.centerYAnchor),
            volumeUnitLabel.leadingAnchor.constraint(equalTo: volumeTextField.trailingAnchor, constant: fieldSpacing),

            // 純アルコール量ラベルとフィールド
            pureAlcoholLabel.topAnchor.constraint(equalTo: volumeLabel.bottomAnchor, constant: 20),
            pureAlcoholLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            pureAlcoholLabel.widthAnchor.constraint(equalToConstant: labelWidth),
            
            pureAlcoholTextField.centerYAnchor.constraint(equalTo: pureAlcoholLabel.centerYAnchor),
            pureAlcoholTextField.leadingAnchor.constraint(equalTo: pureAlcoholLabel.trailingAnchor, constant: fieldSpacing),
            pureAlcoholTextField.widthAnchor.constraint(equalToConstant: 100),
            
            pureAlcoholUnitLabel.centerYAnchor.constraint(equalTo: pureAlcoholTextField.centerYAnchor),
            pureAlcoholUnitLabel.leadingAnchor.constraint(equalTo: pureAlcoholTextField.trailingAnchor, constant: fieldSpacing),
            
            // 保存ボタン
            saveButton.topAnchor.constraint(equalTo: pureAlcoholTextField.bottomAnchor, constant: 40),
            saveButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }

    private func populateData() {
        // データをUIに反映
        if let preset = preset {
            nameTextField.text = preset.name
            alcoholPercentageTextField.text = "\(preset.alcoholPercentage)"
            volumeTextField.text = "\(preset.volume)"
        }
    }

    @objc private func saveButtonTapped() {
        // 入力内容を検証
        guard let name = nameTextField.text,
              let alcoholPercentageText = alcoholPercentageTextField.text,
              let alcoholPercentage = Double(alcoholPercentageText),
              let volumeText = volumeTextField.text,
              let volume = Int(volumeText) else {
            // 入力が不完全な場合は処理を中断
            print("入力値が不正です!")
            return
        }

        // 新しいプリセットを作成
        let newPreset = AlcoholPreset(name: name, alcoholPercentage: alcoholPercentage, volume: volume)

        // デリゲートを通じてデータを戻す
        delegate?.didSavePreset(newPreset, at: index)
        navigationController?.popViewController(animated: true)
    }
    
    @objc private func textFieldDidChange(_ textField: UITextField) {
        updatePureAlcoholField()
    }

    private func updatePureAlcoholField() {
        guard let alcoholPercentageText = alcoholPercentageTextField.text,
              let alcoholPercentage = Double(alcoholPercentageText),
              let volumeText = volumeTextField.text,
              let volume = Int(volumeText) else {
            pureAlcoholTextField.text = ""
            return
        }

        let preset = AlcoholPreset(name: "", alcoholPercentage: alcoholPercentage, volume: volume)
        pureAlcoholTextField.text = "\(preset.pureAlcohol)"
    }
}

CustomTableViewCell.swift: カスタムセルのデザイン

アルコールプリセットを表示するためのカスタムデザインセルを実装しています。

主な機能:

注目ポイント:

ソースコード

//  CustomTableViewCell.swift
//  AlcoholPresetSampleApp
//
//  Created by Toshihiko Arai on 2024/12/12.

import UIKit

class CustomTableViewCell: UITableViewCell {
    // 各項目ラベル
    private let alcoholKeyLabel = UILabel()
    private let volumeKeyLabel = UILabel()
    private let pureAlcoholKeyLabel = UILabel()

    // 各値ラベル
    private let nameValueLabel = UILabel()
    private let alcoholValueLabel = UILabel()
    private let volumeValueLabel = UILabel()
    private let pureAlcoholValueLabel = UILabel()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }

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

    private func setupUI() {
        // 項目ラベル共通設定
        func configureKeyLabel(_ label: UILabel, text: String) {
            label.text = text
            label.textAlignment = .right
            label.font = UIFont.systemFont(ofSize: 14, weight: .medium)
            label.translatesAutoresizingMaskIntoConstraints = false
        }

        configureKeyLabel(alcoholKeyLabel, text: "アルコール度数:")
        configureKeyLabel(volumeKeyLabel, text: "量:")
        configureKeyLabel(pureAlcoholKeyLabel, text: "純アルコール:")

        // 値ラベル共通設定
        func configureValueLabel(_ label: UILabel) {
            label.textAlignment = .left
            label.font = UIFont.systemFont(ofSize: 14)
            label.translatesAutoresizingMaskIntoConstraints = false
        }

        nameValueLabel.font = UIFont.systemFont(ofSize: 16, weight: .bold)
        nameValueLabel.translatesAutoresizingMaskIntoConstraints = false

        configureValueLabel(alcoholValueLabel)
        configureValueLabel(volumeValueLabel)
        configureValueLabel(pureAlcoholValueLabel)

        // ラベルをコンテンツビューに追加
        contentView.addSubview(nameValueLabel)
        contentView.addSubview(alcoholKeyLabel)
        contentView.addSubview(alcoholValueLabel)
        contentView.addSubview(volumeKeyLabel)
        contentView.addSubview(volumeValueLabel)
        contentView.addSubview(pureAlcoholKeyLabel)
        contentView.addSubview(pureAlcoholValueLabel)

        // Auto Layout 制約
        NSLayoutConstraint.activate([
            // 名前ラベル
            nameValueLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
            nameValueLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
            nameValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),

            // アルコール度数ラベル
            alcoholKeyLabel.topAnchor.constraint(equalTo: nameValueLabel.bottomAnchor, constant: 15),
            alcoholKeyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
            alcoholKeyLabel.widthAnchor.constraint(equalToConstant: 120),

            alcoholValueLabel.topAnchor.constraint(equalTo: alcoholKeyLabel.topAnchor),
            alcoholValueLabel.leadingAnchor.constraint(equalTo: alcoholKeyLabel.trailingAnchor, constant: 10),
            alcoholValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),

            // 量ラベル
            volumeKeyLabel.topAnchor.constraint(equalTo: alcoholKeyLabel.bottomAnchor, constant: 10),
            volumeKeyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
            volumeKeyLabel.widthAnchor.constraint(equalToConstant: 120),

            volumeValueLabel.topAnchor.constraint(equalTo: volumeKeyLabel.topAnchor),
            volumeValueLabel.leadingAnchor.constraint(equalTo: volumeKeyLabel.trailingAnchor, constant: 10),
            volumeValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),

            // 純アルコール量ラベル
            pureAlcoholKeyLabel.topAnchor.constraint(equalTo: volumeKeyLabel.bottomAnchor, constant: 10),
            pureAlcoholKeyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
            pureAlcoholKeyLabel.widthAnchor.constraint(equalToConstant: 120),

            pureAlcoholValueLabel.topAnchor.constraint(equalTo: pureAlcoholKeyLabel.topAnchor),
            pureAlcoholValueLabel.leadingAnchor.constraint(equalTo: pureAlcoholKeyLabel.trailingAnchor, constant: 10),
            pureAlcoholValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),
            pureAlcoholValueLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10)
        ])
    }

    // データを設定するためのメソッド
    func configure(name: String, alcoholPercentage: Double, volume: Int, pureAlcohol: Double) {
        nameValueLabel.text = name
        alcoholValueLabel.text = "\(String(format: "%.1f", alcoholPercentage))%"
        volumeValueLabel.text = "\(volume)ml"
        pureAlcoholValueLabel.text = "\(pureAlcohol)ml"
    }
}

AlcoholPreset.swift: プリセットデータモデル

アルコールプリセットのデータ構造を定義するモデルクラスです。

注目ポイント:

ソースコード

//
//  AlcoholPreset.swift
//  AlcoholPresetSampleApp
//
//  Created by Toshihiko Arai on 2024/12/12.
//

import Foundation

class AlcoholPreset {
    var name: String
    var alcoholPercentage: Double // アルコール度数 (%)
    var volume: Int // 量 (ml)
    var pureAlcohol: Double { // 純アルコール量 (ml)
        let val = (alcoholPercentage / 100) * Double(volume) * 0.8
        return round(val * 10) / 10 // 小数点以下で1桁四捨五入
    }
    
    init(name: String, alcoholPercentage: Double, volume: Int) {
        self.name = name
        self.alcoholPercentage = alcoholPercentage
        self.volume = volume
    }
}

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。

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

Amazonで探す