断酒iOSアプリ開発への道
【開発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()
.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
label.textColor = .black
labelreturn label
}()
override func viewDidLoad() {
super.viewDidLoad()
()
setupUI()
setupDays()
updateMonthLabel}
func setMonth(date: Date) {
self.currentMonth = date
}
private func setupUI() {
// 月ラベルの設定
.addSubview(monthLabel)
view.backgroundColor = .white
view
.activate([
NSLayoutConstraint.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)
monthLabel])
// カレンダー部分のレイアウト
let layout = UICollectionViewFlowLayout()
.minimumLineSpacing = 1
layout.minimumInteritemSpacing = 1
layout.itemSize = CGSize(width: view.bounds.width / 7 - 1, height: view.bounds.width / 5 - 1)
layout
= UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView .translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .white
collectionView.register(CustomCalendarCell.self, forCellWithReuseIdentifier: CustomCalendarCell.identifier)
collectionView.dataSource = self
collectionView.delegate = self
collectionView
.addSubview(collectionView)
view
.activate([
NSLayoutConstraint.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)
collectionView])
}
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
= weekdays
days .append(contentsOf: Array(repeating: "", count: weekday - 1))
days.append(contentsOf: (1...daysInMonth).map { String($0) })
days
.reloadData()
collectionView}
private func updateMonthLabel() {
let formatter = DateFormatter()
.dateFormat = "yyyy年 MM月"
formatter.text = formatter.string(from: currentMonth)
monthLabel}
}
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 { // 日曜日
= .red
textColor } else if column == 6 { // 土曜日
= .gray
textColor }
.configure(day: day, isToday: isToday, textColor: textColor)
cellreturn 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 &&
.month == currentComponents.month &&
todayComponents.day == day
todayComponents}
}
extension CalendarMonthViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let selectedDay = days[indexPath.item]
guard !selectedDay.isEmpty else { return } // 空白セルを無視
(for: selectedDay)
presentFormView}
private func presentFormView(for day: String) {
(day)
print}
}
ポイント
- 月のラベルを表示 - 現在の年月をヘッダーに表示します。
- セル配置 - セルごとに曜日や日付を並べ、視覚的にカレンダーを作成します。
- 今日の日付をハイライト - 現在の日付を背景色で強調することで、ユーザーに分かりやすく表示します。
カスタムセルの設定
各日付を表示するためのCustomCalendarCellクラスを定義します。このクラスで日付の表示やハイライト処理を行います。
import UIKit
class CustomCalendarCell: UICollectionViewCell {
static let identifier = "CustomCalendarCell"
private let dayLabel: UILabel = {
let label = UILabel()
.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 14, weight: .medium)
labelreturn label
}()
override init(frame: CGRect) {
super.init(frame: frame)
.addSubview(dayLabel)
contentView
.activate([
NSLayoutConstraint.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) // 高さを固定
dayLabel])
.layer.cornerRadius = 10
contentView.layer.masksToBounds = true
contentView}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(day: String, isToday: Bool = false, textColor: UIColor = .black) {
.text = day
dayLabel.textColor = textColor
dayLabel.backgroundColor = isToday ? .systemYellow : .clear
contentView}
}
今日の日付をハイライトする仕組み
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 { // 日曜日
= .red
textColor } else if column == 6 { // 土曜日
= .gray
textColor }
.configure(day: day, isToday: isToday, textColor: textColor)
cellreturn 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 &&
.month == currentComponents.month &&
todayComponents.day == day
todayComponents}
}
前月・次月をスムーズに切り替える
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()
.backgroundColor = .white
view()
setupPageViewController}
private func setupPageViewController() {
// UIPageViewControllerを初期化
= UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
pageViewController .dataSource = self
pageViewController.delegate = self
pageViewController
// 初期ページを設定
let initialVC = CalendarMonthViewController()
.setMonth(date: currentMonth)
initialVC.setViewControllers([initialVC], direction: .forward, animated: false, completion: nil)
pageViewController
// ページビューを親ビューに追加
(pageViewController)
addChild.addSubview(pageViewController.view)
view.didMove(toParent: self)
pageViewController
// レイアウト設定
.view.translatesAutoresizingMaskIntoConstraints = false
pageViewController.activate([
NSLayoutConstraint.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)
pageViewController])
}
}
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()
.setMonth(date: previousMonth)
previousVCreturn 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()
.setMonth(date: nextMonth)
nextVCreturn 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()
.rootViewController = rootViewController
windowself.window = window
.makeKeyAndVisible()
window}
StoryBoardを使わないことで、柔軟なレイアウト変更や動作確認が可能です。
まとめ
この記事では、断酒カレンダーアプリの基本構造を説明しました。以下のステップを進めることで、機能的なカレンダーアプリを構築できます。 1. UICollectionViewでカレンダーを作成 2. 現在の日付をハイライト表示 3. UIPageViewControllerで月の切り替えを実装
断酒を続けるために役立つアプリを一緒に作っていきましょう。次回は、記録機能や通知機能の実装について紹介します!
ダウンロード
このブログ記事で使用しているコードはGitHubで公開していますので、気軽にダウンロードしてご活用ください。 - https://github.com/aragig/ios_sample_dansyu
今回のサンプルカレンダーは、CalenderSampleAppターゲットをビルドすると再現できます。
【開発3日目】飲酒量入力画面をつくる
飲酒量を入力できるシンプルな画面を作成し、ユーザーが飲酒状況を直感的に記録できるようにすることを目指します。この画面は、断酒や飲酒管理をサポートするアプリの重要な部分となります。

今回は以下の3つのポイントに焦点を当てて進めます。
飲酒の有無を選択できるボタンの作成 「はい」「いいえ」のボタンを使って飲酒の有無を選択し、選択内容に応じて飲酒量入力画面を切り替える仕組みを実装します。
飲酒量の入力インターフェース 飲酒量を選択できる
AlcoholSelectionView
を作成します。各アルコール飲料をタップして追加できるUIを実装します。履歴の管理と操作性の向上 Undoボタンを実装し、選択した内容を取り消せるようにします。また、入力内容を可視化し、合計の純アルコール量をリアルタイムで計算します。
飲酒量入力画面を構築する FormViewController
FormViewController
は、飲酒量入力画面の主要なコントローラーです。以下の機能を実装しています。
- 日付表示: 日付ラベルで記録する日を明示。
- 飲酒の有無の選択: 「はい」「いいえ」のボタンを表示し、選択結果に応じて飲酒量入力画面を表示/非表示にする。
- 動的なボタンスタイル: 選択されたボタンにハイライトを適用。
ソースコード
以下は FormViewController
の実装コードです。
import UIKit
class FormViewController: UIViewController {
var selectedDay: Date? // 選択された日付を保持するプロパティ
private let dayLabel: UILabel = {
let label = UILabel()
.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
label.textAlignment = .center
label.textColor = .black
labelreturn label
}()
private let questionLabel: UILabel = {
let label = UILabel()
.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 24, weight: .semibold)
label.textAlignment = .center
label.textColor = .black
label.text = "お酒を飲みましたか?"
labelreturn label
}()
private let yesButton: UIButton = {
let button = UIButton(type: .system)
.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
buttonreturn button
}()
private let noButton: UIButton = {
let button = UIButton(type: .system)
.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
buttonreturn button
}()
private let alcoholSelectionView: AlcoholSelectionView = {
let view = AlcoholSelectionView()
.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true // 初期状態で非表示
viewreturn view
}()
override func viewDidLoad() {
super.viewDidLoad()
.backgroundColor = .white
view
()
setupUI()
setupActions}
private func setupUI() {
.addSubview(dayLabel)
view.addSubview(questionLabel)
view.addSubview(yesButton)
view.addSubview(noButton)
view.addSubview(alcoholSelectionView)
view
.activate([
NSLayoutConstraint.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
dayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
dayLabel
.topAnchor.constraint(equalTo: dayLabel.bottomAnchor, constant: 40),
questionLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
questionLabel
.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),
yesButton
.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),
noButton
.topAnchor.constraint(equalTo: yesButton.bottomAnchor, constant: 40),
alcoholSelectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0),
alcoholSelectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0),
alcoholSelectionView// alcoholSelectionView.heightAnchor.constraint(equalToConstant: 200)
.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10) // 下端に余白を取る
alcoholSelectionView])
if let selectedDay = selectedDay {
let formatter = DateFormatter()
.dateFormat = "yyyy年MM月dd日"
formatter.text = "\(formatter.string(from: selectedDay))"
dayLabel}
}
private func setupActions() {
.addTarget(self, action: #selector(didTapYes), for: .touchUpInside)
yesButton.addTarget(self, action: #selector(didTapNo), for: .touchUpInside)
noButton}
@objc private func didTapYes() {
(yesButton, isSelected: true)
highlightButton(noButton, isSelected: false)
highlightButton.isHidden = false // 「はい」で表示
alcoholSelectionView}
@objc private func didTapNo() {
(yesButton, isSelected: false)
highlightButton(noButton, isSelected: true)
highlightButton.isHidden = true // 「いいえ」で非表示
alcoholSelectionView}
private func highlightButton(_ button: UIButton, isSelected: Bool) {
if isSelected {
.backgroundColor = UIColor.systemBlue // ハイライト色
button.setTitleColor(.white, for: .normal)
button} else {
.backgroundColor = UIColor.fromHex("#CCCCCC") // デフォルト色
button.setTitleColor(.white, for: .normal)
button}
}
}
extension UIColor {
static func fromHex(_ hex: String, alpha: CGFloat = 1.0) -> UIColor {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
= hexSanitized.replacingOccurrences(of: "#", with: "")
hexSanitized
var rgb: UInt64 = 0
(string: hexSanitized).scanHexInt64(&rgb)
Scanner
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 の実装コードです。
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桁で切り捨て
.text = "合計純アルコール量\n\(truncatedValue)g" // 数字部分を改行
totalLabel}
}
private var historyStack: [(selectedItems: [(name: String, count: Int)], totalPureAlcoholContent: Double)] = [] // 履歴管理
private let totalLabel: UILabel = {
let label = UILabel()
.font = UIFont.systemFont(ofSize: 22, weight: .semibold)
label.textAlignment = .center
label.text = "合計純アルコール量\n0g" // 改行をデフォルトで挿入
label.numberOfLines = 0 // 折り返しを有効にする
label.translatesAutoresizingMaskIntoConstraints = false
labelreturn label
}()
private let listLabel: UILabel = {
let label = UILabel()
.font = UIFont.systemFont(ofSize: 16, weight: .regular)
label.textAlignment = .center
label.numberOfLines = 0
label.text = ""
label.translatesAutoresizingMaskIntoConstraints = false
labelreturn label
}()
private lazy var undoButton: UIButton = {
let button = UIButton(type: .system)
let icon = UIImage(systemName: "arrow.uturn.left") // Undoアイコン
.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
buttonreturn button
}()
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
.scrollDirection = .horizontal // 横スクロールに設定
layout.estimatedItemSize = CGSize(width: 50, height: 40) // 推定サイズを指定して自動調整
layout.minimumLineSpacing = 10
layout.minimumInteritemSpacing = 10
layout
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
.translatesAutoresizingMaskIntoConstraints = false
collectionView.register(AlcoholOptionCell.self, forCellWithReuseIdentifier: AlcoholOptionCell.identifier)
collectionView.dataSource = self
collectionView.delegate = self
collectionView.showsHorizontalScrollIndicator = true
collectionView.backgroundColor = .clear
collectionViewreturn collectionView
}()
override init(frame: CGRect) {
super.init(frame: frame)
()
setupView}
required init?(coder: NSCoder) {
super.init(coder: coder)
()
setupView}
private func setupView() {
= .white
backgroundColor
(totalLabel)
addSubview(listLabel)
addSubview(undoButton)
addSubview(collectionView)
addSubview
.activate([
NSLayoutConstraint.topAnchor.constraint(equalTo: topAnchor, constant: 10),
totalLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
totalLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
totalLabel
.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),
undoButton
.topAnchor.constraint(equalTo: undoButton.bottomAnchor, constant: 10), // listLabelをundoButtonの下に配置
listLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
listLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
listLabel
.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)
collectionView])
}
private func saveCurrentStateToHistory() {
.append((selectedItems: selectedItems, totalPureAlcoholContent: totalPureAlcoholContent))
historyStack}
@objc private func didTapUndoButton() {
guard let previousState = historyStack.popLast() else { return } // 履歴が空なら何もしない
= previousState.selectedItems
selectedItems = previousState.totalPureAlcoholContent
totalPureAlcoholContent () // ラベルを更新
updateListLabel}
private func updateListLabel() {
if selectedItems.isEmpty {
.text = ""
listLabel} else {
.text = selectedItems.map { "\($0.name) x \($0.count)" }.joined(separator: "\n")
listLabel}
}
}
// 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]
.configure(with: option.name)
cellreturn cell
}
}
// MARK: - UICollectionViewDelegate
extension AlcoholSelectionView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
() // 現在の状態を保存
saveCurrentStateToHistorylet option = alcoholOptions[indexPath.item]
// 既存項目を探す
if let index = selectedItems.firstIndex(where: { $0.name == option.name }) {
[index].count += 1 // カウントを更新
selectedItems} else {
.append((name: option.name, count: 1)) // 新しい項目を追加
selectedItems}
+= option.pureAlcoholContent
totalPureAlcoholContent ()
updateListLabel}
}
// MARK: - AlcoholOptionCell
class AlcoholOptionCell: UICollectionViewCell {
static let identifier = "AlcoholOptionCell"
private let titleLabel: UILabel = {
let label = UILabel()
.font = UIFont.systemFont(ofSize: 16, weight: .bold)
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 1
label.setContentCompressionResistancePriority(.required, for: .horizontal) // 圧縮防止
labelreturn label
}()
override init(frame: CGRect) {
super.init(frame: frame)
.addSubview(titleLabel)
contentView.backgroundColor = UIColor.systemBlue
contentView.layer.cornerRadius = 20
contentView.layer.masksToBounds = true
contentView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.activate([
NSLayoutConstraint.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)
titleLabel])
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
func configure(with title: String) {
.text = title
titleLabel}
}
UIの構成と機能説明
飲酒有無ボタン
「はい」「いいえ」のボタンを実装し、ユーザーが飲酒の有無を選択できるようにしています。選択したボタンは背景色を変更してハイライトします。
アルコール選択ビュー
AlcoholSelectionView 内では、以下の操作が可能です。 - 飲料の選択: 横スクロール可能なリストから飲料を選択し、ボタンをタップするごとに本数がカウントされます。 - 合計純アルコール量の表示: 選択した飲料の純アルコール量を計算し、合計を表示します。 - Undo機能: 最新の選択を取り消して、直前の状態に戻ることができます。
Undoボタンの動作
@objc private func didTapUndoButton() {
guard let previousState = historyStack.popLast() else { return }
= previousState.selectedItems
selectedItems = previousState.totalPureAlcoholContent
totalPureAlcoholContent ()
updateListLabel}
Undoボタンを押すと、最新の履歴をスタックからポップして、状態を更新します。
@IBAction func onTappedClick(_ sender: Any) {
let formVC = FormViewController() // フォームビューのコントローラー
.selectedDay = Date() // 日付をDate型で渡す
formVC.modalPresentationStyle = .formSheet // モーダルスタイルを設定
formVC(formVC, animated: true, completion: nil)
present}
今後の課題
データ保存機能の追加 現在は選択内容がアプリ内に保持されるのみで、永続化されません。次のステップでは、Core Data や UserDefaults を使ったデータ保存機能を実装します。
カスタマイズ性の向上 ユーザーが飲料リストを編集できるようにし、新しい飲料や特定の飲料を登録可能にします。
視覚的デザインの改善 現在のUIはシンプルですが、グラフィックやアニメーションを追加することで、より洗練された体験を提供します。
コードを試してみる
この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。
今回のサンプルアプリは、AlcoholFormSampleAppターゲットをビルドすると再現できます。
この記事では、断酒アプリの飲酒量入力画面の基盤を構築しました。この画面を通じて、ユーザーは飲酒の有無を記録し、選択した飲酒量を視覚的に確認できます。
【開発5日目】飲酒量の視覚化 プログレスバーを作る
断酒アプリ制作5日目の本日は、純アルコール量を計算し、飲酒量に応じた色の変化やリスクレベルをわかりやすく表示する「縦型プログレスバー」を作成します。これにより、数値で飲酒量を認識するよりも、ユーザーは自身の飲酒状況を一目で把握できるため、飲み過ぎかどうかの判断がつきやすくなります。
下の動画のような縦型プログレスバーを作ることがゴールになります。
アプリケーションで飲酒量を視覚化するために、純アルコール量を計算し、その危険度をユーザーに示すインターフェースを構築します。本記事では、VerticalProgressBar クラスを作成し、飲酒量を視覚的に表示する方法を解説します。
純アルコール量の計算
純アルコール量を以下の式で計算します。
\[ 純アルコール量 (g) = 飲酒量 (ml) \times アルコール度数 (\%) \times 0.8 \]
この値に基づき、飲酒リスクを段階的に分類し、視覚化することで、飲酒量を直感的に理解できるようにします。
危険度の分類
ちなみに純アルコール量に基づく危険度は、以下のように段階的に分類されます。
純アルコール量 (g/日) | 危険度の段階 | 健康への影響 |
---|---|---|
0~19g | 低リスク | 健康影響は少ないが、習慣化に注意が必要。 |
20~39g | 中リスク | 肝臓疾患や高血圧のリスクがわずかに増加。 |
40~59g | 高リスク | アルコール依存症や肝臓疾患のリスク増加。 |
60g以上 | 非常に高リスク | アルコール依存症、肝硬変、心血管疾患の危険大。 |
※ 基準は一般的なものです。国や医療機関によって異なる場合があります
注意
- 週単位での評価: 世界保健機関(WHO)や各国のガイドラインでは、週単位での飲酒量の指針を設けています。
- 例: 日本では「男性で1日あたり純アルコール量40g以下、女性で20g以下」が目安とされています。
- 連続飲酒の回避: 少なくとも週2日は休肝日を設けることが推奨されています。
- 個人差: 体重、性別、年齢、体質により影響が異なります。
プログレスバーの実装 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 {
= newValue
internalProgress ()
updateProgress}
}
override init(frame: CGRect) {
super.init(frame: frame)
()
setupViews}
required init?(coder: NSCoder) {
super.init(coder: coder)
()
setupViews}
private func setupViews() {
// 背景コンテナ
.layer.borderWidth = 1
containerView.layer.borderColor = UIColor.lightGray.cgColor
containerView.clipsToBounds = true
containerView.layer.cornerRadius = 5 // 背景のみ角丸にする
containerView(containerView)
addSubview
// プログレスバー
.backgroundColor = .green
progressBar.addSubview(progressBar)
containerView
// 最大値を示す矢印を作成
.backgroundColor = .clear
arrowView(arrowView)
addSubview
// 現在の状態を示す矢印を作成
.backgroundColor = .clear
currentArrowView(currentArrowView)
addSubview
}
// レイアウトの更新
override func layoutSubviews() {
super.layoutSubviews()
.frame = bounds
containerView()
updateProgress}
// プログレスバーの更新
private func updateProgress() {
let containerHeight = containerView.bounds.height
// プログレスバーの高さを計算
let progressHeight = CGFloat(min(internalProgress / maxAlcoholContent, 1.0)) * containerHeight
// アニメーションでプログレスバーの増加をスムーズに
.animate(withDuration: 0.3, animations: {
UIViewself.progressBar.frame = CGRect(
: 0,
x: containerHeight - progressHeight,
y: self.containerView.bounds.width,
width: progressHeight
height)
})
// 矢印の位置を更新
let arrowPosition: CGFloat
if internalProgress > maxAlcoholContent {
= CGFloat((maxAlcoholContent / internalProgress) * containerHeight)
arrowPosition } else {
= CGFloat(containerHeight)
arrowPosition }
(position: arrowPosition)
updateArrow(position: containerHeight - progressHeight)
updateCurrentArrow
// 色のアニメーションを追加
(for: progressBar, to: calculateColor(for: internalProgress))
animateColorChange}
// 色のアニメーション
private func animateColorChange(for view: UIView, to color: UIColor) {
.transition(with: view, duration: 0.3, options: [.transitionCrossDissolve], animations: {
UIView.backgroundColor = color
view})
}
// 進捗に応じた色を計算
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
.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha)
from.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha)
to
// 線形補間で色を計算
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()
// 三角形の描画(右側)
.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)) // 棒の終点
path
let shapeLayer = CAShapeLayer()
.path = path.cgPath
shapeLayer.fillColor = UIColor.black.cgColor
shapeLayer.strokeColor = UIColor.white.cgColor // 水平棒の色
shapeLayer.lineWidth = 2.0 // 水平棒の太さ
shapeLayer
.layer.sublayers?.forEach { $0.removeFromSuperlayer() } // 既存の三角形を削除
arrowView.layer.addSublayer(shapeLayer)
arrowView
// アニメーションで矢印の移動をスムーズに
.animate(withDuration: 0.3, animations: {
UIViewself.arrowView.frame = CGRect(
: self.containerView.frame.maxX + 5,
x: self.bounds.height - position - (arrowHeight / 2) + 2,
y: arrowWidth,
width: arrowHeight
height)
})
// ラベルを追加または更新
if let label = viewWithTag(9001) as? UILabel {
.animate(withDuration: 0.3, animations: {
UIView.frame = CGRect(
label: self.arrowView.frame.maxX + 5,
x: self.arrowView.frame.midY - 10,
y: 80,
width: 20
height)
})
} else {
let label = UILabel()
.text = maxAlcoholContentTitle
label.font = UIFont.systemFont(ofSize: 12)
label.textColor = .black
label.tag = 9001 // ラベルをタグで管理
label.frame = CGRect(
label: arrowView.frame.maxX + 5,
x: arrowView.frame.midY - 10,
y: 80,
width: 20
height)
(label)
addSubview}
}
// 現在の状態を示す矢印の位置を更新
private func updateCurrentArrow(position: CGFloat) {
let arrowWidth: CGFloat = 10
let arrowHeight: CGFloat = 10
// 矢印を三角形に描画(現在の状態を示す矢印)
let path = UIBezierPath()
.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()
path
let shapeLayer = CAShapeLayer()
.path = path.cgPath
shapeLayer.fillColor = UIColor.black.cgColor
shapeLayer
.layer.sublayers?.forEach { $0.removeFromSuperlayer() } // 既存の三角形を削除
currentArrowView.layer.addSublayer(shapeLayer)
currentArrowView
// アニメーションで矢印の移動をスムーズに
.animate(withDuration: 0.3, animations: {
UIViewself.currentArrowView.frame = CGRect(
: self.containerView.frame.minX - 15,
x: position - 5,
y: arrowWidth,
width: arrowHeight
height)
})
// ラベルを追加または更新
if let label = viewWithTag(9002) as? UILabel {
// ラベルの値を更新
.text = String(format: "%.1fg", internalProgress)
label.animate(withDuration: 0.3, animations: {
UIView.frame = CGRect(
label: self.currentArrowView.frame.minX - 55, // 三角形の左側に配置
x: self.currentArrowView.frame.midY - 10,
y: 50,
width: 20
height)
})
} else {
let label = UILabel()
.text = String(format: "%.1fg", internalProgress)
label.font = UIFont.systemFont(ofSize: 12)
label.textColor = .black
label.textAlignment = .right
label.tag = 9002 // ラベルをタグで管理
label.frame = CGRect(
label: currentArrowView.frame.minX - 55, // 三角形の左側に配置
x: currentArrowView.frame.midY - 10,
y: 50,
width: 20
height)
(label)
addSubview}
}
// 水平線を描画
private func drawHorizontalLine(at yPosition: CGFloat) {
// 既存の水平線を削除
.layer.sublayers?.removeAll(where: { $0.name == "HorizontalLine" })
containerView
// 水平線を描画
let linePath = UIBezierPath()
.move(to: CGPoint(x: 0, y: yPosition))
linePath.addLine(to: CGPoint(x: containerView.bounds.width, y: yPosition))
linePath
let lineLayer = CAShapeLayer()
.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" // 識別のため名前を付ける
lineLayer
.layer.addSublayer(lineLayer)
containerView}
// リセット機能
func reset() {
= 0.0
internalProgress ()
updateProgress
// ラベルを削除
if let label = viewWithTag(9001) as? UILabel {
.removeFromSuperview()
label}
}
}
主なプロパティと機能
- progress: 現在の純アルコール量を設定します。
- maxAlcoholContent: プログレスバーの最大値(例:60g)。
- updateProgress(): プログレスバーを更新し、進捗に応じた色や矢印を表示します。
カラーの変化
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()
// プログレスバーをプログラムで追加
.frame = CGRect(x: 150, y: 100, width: 30, height: 300)
progressBar.maxAlcoholContent = 60.0 // 最大値を設定
progressBar.addSubview(progressBar)
view
...
}
ボタンによる進捗の更新
ボタンを押すたびにランダムなアルコール量を追加し、プログレスバーとラベルを更新します。
@IBAction func onTappedClick(_ sender: Any) {
let randomAlcohol = Double.random(in: 2.0...10.0)
+= randomAlcohol
totalPureAlcoholContent
.progress = totalPureAlcoholContent
progressBar.text = String(format: "%.1f g", totalPureAlcoholContent)
totalLabel}
リセット機能
飲酒量とプログレスバーをリセットします。
@IBAction func onTappedReset(_ sender: UIButton) {
= 0.0
totalPureAlcoholContent .reset()
progressBar.text = String(format: "%.1f g", totalPureAlcoholContent)
totalLabel}
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)
// ボタンの設定
.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)
button
// ラベルの設定
.text = labelText
label.font = UIFont.systemFont(ofSize: 14)
label.textColor = .darkGray
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
label
// サブビューを追加
(button)
addSubview(label)
addSubview
// レイアウトを設定
.activate([
NSLayoutConstraint.widthAnchor.constraint(equalTo: self.widthAnchor), // 親ビューの幅にフィット
button.heightAnchor.constraint(equalTo: button.widthAnchor), // 正方形にする
button.centerXAnchor.constraint(equalTo: self.centerXAnchor),
button.topAnchor.constraint(equalTo: self.topAnchor),
button
.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 8),
label.centerXAnchor.constraint(equalTo: self.centerXAnchor),
label.bottomAnchor.constraint(equalTo: self.bottomAnchor)
label])
}
@objc private func buttonTapped() {
?() // コールバックを実行
onTap}
override func layoutSubviews() {
super.layoutSubviews()
.layer.cornerRadius = button.bounds.width / 2 // ボタンを円形にする
button}
// ボタンの背景色を変更するメソッド
func updateButtonBackgroundColor(_ color: UIColor) {
.backgroundColor = color
button}
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() {
= 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
textView
.addSubview(textView)
view
.activate([
NSLayoutConstraint.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) // 高さを固定
textView])
}
private func setupEmotionButtons() {
let emotions = EmotionLevel.allCases.filter { $0 != .notSelected }
// スタックビューで配置
let stackView = UIStackView()
.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .fillEqually
stackView.spacing = 16
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addSubview(stackView)
view
.activate([
NSLayoutConstraint.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)
stackView])
for emotion in emotions {
let icon = UIImage(systemName: "face.smiling") // 任意のアイコン
let circleButtonView = CircleButtonView(icon: icon, labelText: emotion.label)
.onTap = { [weak self] in
circleButtonViewself?.selectedEmotion = emotion
}
.addArrangedSubview(circleButtonView)
stackView.append(circleButtonView)
circleButtonViews}
()
updateButtonStates}
private func updateButtonStates() {
for (index, buttonView) in circleButtonViews.enumerated() {
let emotion = EmotionLevel(rawValue: index + 1) // `notSelected`を除外した分ずらす
let isSelected = emotion == selectedEmotion
.updateButtonBackgroundColor(isSelected ? .systemPink : .lightGray) // ボタンの背景色を更新
buttonView}
}
private func setupDismissKeyboardGesture() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
.addGestureRecognizer(tapGesture)
view}
@objc private func dismissKeyboard() {
.endEditing(true) // キーボードを閉じる
view}
}
キーボードを閉じるジェスチャーの設定について
iOSアプリでは、UITextView
や UITextField
に文字を入力した後、画面のどこかをタップしてキーボードを閉じたいというケースがよくあります。しかし、デフォルトではタップしてもキーボードが閉じないため、明示的にその処理を実装する必要があります。
setupDismissKeyboardGesture
メソッドで、UITapGestureRecognizer
を使ってタップを検知するジェスチャーを作成し、画面全体
(view
)
に追加しています。これでタップジェスチャーを利用してキーボードを閉じる仕組みが実現できます。
view.addGestureRecognizer(tapGesture)
のジェスチャーをビューに追加することで、タップイベントを検知できるようにしています。タップジェスチャーによって呼び出される
dismissKeyboard
メソッドの中では、view.endEditing(true)
を使用して現在のファーストレスポンダー(=入力中の
UITextView
や
UITextField
)の編集状態を終了します。これにより、キーボードが非表示になります。true
を指定すると、すべてのファーストレスポンダーを終了対象とします。
コードを試してみる
この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。
今回のサンプルアプリは、EmotionFormSampleAppターゲットをビルドすると再現できます。
【開発9日目】Realmでデータの永続化
断酒してから9日が経過しました!お酒のない生活にも少しずつ慣れ、空いた時間で新しいことに挑戦する余裕も生まれてきました。その一環として、アプリ開発をコツコツ進めております。健康志向が芽生えてきたこともあり、ジョギングを趣味として始めたのも最近の大きな変化です。普段は30分ほど、3km〜5kmを目安に走る程度ですが、ある日妙に調子が良く、40km近く走る暴挙に出た結果、膝を痛めるという大失態を犯しました。その後1ヶ月ほど苦しみましたが、ようやく膝も治り、再びジョギング生活が戻ってきました。
そんな中、新たな相棒としてランニングシューズを購入しました。つい先日まではAmazonで適当に買った3000円のシューズを履いていましたが、これが微妙に大きく、走りづらさを感じる原因だったようです。先日、 高尾山に登った帰り に新宿のL-Breathに立ち寄り、アシックスのシューズを購入することに。
驚いたのは、店舗に備わっている最新鋭のレーザースキャン。足のサイズを正確に計測し、自分に合った靴を即座に提案してくれました。結果、自分が想定していたよりも足のサイズが小さいことが発覚!一方で横幅が広いため、サイズ選びに苦戦していた理由がようやくわかりました。新しいシューズは「これだ!」と思える履き心地で、地面からの衝撃をしっかり吸収してくれそうです。これなら膝を痛める心配も少なそう。トレンドの厚底シューズの恩恵に、さっそく期待が高まります。
ここで改めて感じたのは、「適切なツール選び」の重要性です。シューズ一つでこれだけ快適さが変わるのなら、アプリ開発においても適切なツールを選ぶことで効率や完成度に大きな差が出るはずです。
さて、今回進めている断酒サポートアプリの開発でも、この「ツール選び」がポイントとなりました。iOSアプリ開発ではデータの永続化が欠かせませんが、どの方法を使うべきか迷うところです。候補として挙がるのは以下の通り:
- UserDefaults:小規模な設定データの保存向け
- Core Data:強力だが、学習コストが高い
- SQLite:柔軟だが、低レベルな操作が必要
- Realm:シンプルかつ直感的で高速
今回は、セットアップが容易で直感的に使える Realm を選びました。ここからは、このRealmを使ってアプリのデータを永続化する方法について紹介していきます。
Realmの概要
Realmはモバイル向けのデータベースで、以下の特徴があります:
- 高速:SQLiteベースのデータベースより高速な読み書きが可能。
- 簡単:Core Dataと比べてシンプルなAPI設計。
- スキーマの自動管理:手動でデータベースのスキーマを管理する必要がない。
- クロスプラットフォーム対応:iOS、Android、React Nativeなど複数のプラットフォームで使用可能。
CocoaPodsを使用したRealmのインストール
以下のようにPodfile
を編集してRealmをインストールします。
'https://github.com/CocoaPods/Specs.git'
source
:ios, '13.0'
platform
!
use_modular_headers
'RealmSampleApp' do
target !
use_frameworks
'RealmSwift'
pod end
その後、以下のコマンドを実行します:
pod install
これでプロジェクトにRealmが導入されます。
UIの実装
以下のようなUIを準備します。
class ViewController: UIViewController {
// UIコンポーネント
let datePicker: UIDatePicker = {
let picker = UIDatePicker()
.datePickerMode = .date
picker.translatesAutoresizingMaskIntoConstraints = false
pickerreturn picker
}()
let textView: UITextView = {
let textView = UITextView()
.layer.borderColor = UIColor.lightGray.cgColor
textView.layer.borderWidth = 1.0
textView.layer.cornerRadius = 5.0
textView.translatesAutoresizingMaskIntoConstraints = false
textViewreturn textView
}()
let saveButton: UIButton = {
let button = UIButton(type: .system)
.setTitle("保存", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
buttonreturn button
}()
let loadButton: UIButton = {
let button = UIButton(type: .system)
.setTitle("読み込み", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
buttonreturn button
}()
let resultLabel: UILabel = {
let label = UILabel()
.text = "日記がここに表示されます"
label.numberOfLines = 0
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
labelreturn label
}()
let idTextField: UITextField = {
let textField = UITextField()
.placeholder = "削除したいIDを入力"
textField.borderStyle = .roundedRect
textField.translatesAutoresizingMaskIntoConstraints = false
textFieldreturn textField
}()
let deleteButton: UIButton = {
let button = UIButton(type: .system)
.setTitle("削除", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
buttonreturn button
}()
override func viewDidLoad() {
super.viewDidLoad()
.backgroundColor = .white
view()
setupUI
.addTarget(self, action: #selector(saveDiary), for: .touchUpInside)
saveButton.addTarget(self, action: #selector(loadDiary), for: .touchUpInside)
loadButton.addTarget(self, action: #selector(deleteDiary), for: .touchUpInside)
deleteButton
}
// UIのレイアウト設定
func setupUI() {
.addSubview(datePicker)
view.addSubview(textView)
view.addSubview(saveButton)
view.addSubview(loadButton)
view.addSubview(resultLabel)
view.addSubview(idTextField)
view.addSubview(deleteButton)
view
.activate([
NSLayoutConstraint.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
datePicker.centerXAnchor.constraint(equalTo: view.centerXAnchor),
datePicker
.topAnchor.constraint(equalTo: datePicker.bottomAnchor, constant: 20),
textView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
textView.widthAnchor.constraint(equalToConstant: 300),
textView.heightAnchor.constraint(equalToConstant: 150),
textView
.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 20),
saveButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: -70),
saveButton
.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 20),
loadButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 70),
loadButton
.topAnchor.constraint(equalTo: saveButton.bottomAnchor, constant: 30),
resultLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
resultLabel.widthAnchor.constraint(equalToConstant: 300),
resultLabel
.topAnchor.constraint(equalTo: resultLabel.bottomAnchor, constant: 20),
idTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
idTextField.widthAnchor.constraint(equalToConstant: 300),
idTextField
.topAnchor.constraint(equalTo: idTextField.bottomAnchor, constant: 10),
deleteButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
deleteButton
])
}
// 日記を保存する処理
@objc func saveDiary() {
let realm = try! Realm()
let selectedDate = datePicker.date
// プライマリキーがYYYYMMDD形式になるように設定
let dateFormatter = DateFormatter()
.dateFormat = "yyyyMMdd" // 固定フォーマット
dateFormatterlet id = dateFormatter.string(from: selectedDate)
(id) // 例: 20241129
print
// データ作成または更新
let entry = DiaryEntry()
.id = id
entry.date = selectedDate
entry.content = textView.text
entry
try! realm.write {
.add(entry, update: .modified)
realm}
.text = "日記を保存しました!"
resultLabel.text = ""
textView}
...
}
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()
.dateFormat = "yyyyMMdd"
dateFormatterlet id = dateFormatter.string(from: selectedDate)
let entry = DiaryEntry()
.id = id
entry.date = selectedDate
entry.content = textView.text
entry
try! realm.write {
.add(entry, update: .modified)
realm}
.text = "日記を保存しました!"
resultLabel.text = ""
textView}
日記の読み込み
@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 {
.text = "日記: \(entry.content)"
resultLabel.text = entry.id
idTextField} else {
.text = "指定の日付に日記はありません"
resultLabel}
}
日記の削除
@objc func deleteDiary() {
let realm = try! Realm()
guard let id = idTextField.text, !id.isEmpty else {
.text = "IDを入力してください"
resultLabelreturn
}
if let entry = realm.object(ofType: DiaryEntry.self, forPrimaryKey: id) {
try! realm.write {
.delete(entry)
realm}
.text = "ID: \(id) の日記を削除しました"
resultLabel.text = ""
idTextField} else {
.text = "ID: \(id) の日記は存在しません"
resultLabel}
}
マイグレーション方法
アプリのリリース後にデータモデルを変更した場合、マイグレーションが必要です。以下はマイグレーションの設定例です。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let config = Realm.Configuration(
: 2,
schemaVersion: { migration, oldSchemaVersion in
migrationBlockif oldSchemaVersion < 2 {
// 必要なマイグレーション処理
}
}
)
.Configuration.defaultConfiguration = config
Realmreturn true
}
この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。
今回のサンプルアプリは、RealmSampleAppターゲットをビルドすると再現できます。
【開発15日目】紙吹雪アニメーション
断酒を始めてから、今日でちょうど半月が経ちました!おめでとうございます!ありがとうございます!
いやー、この二週間、本当に長かったです。飲酒していた頃は、日々があっという間に過ぎていく感覚でしたが、断酒を始めてからは毎日が充実していて、「まだ2週間しか経ってないの?」と驚くほどです。
断酒後、まず変化を感じたのは「起きている時間の長さ」です。飲酒時と比べて睡眠の質が良くなり、短い睡眠でも十分に疲れが取れるようになりました。その結果、夜の時間を有効に使えるようになり、読書をするのが新しい習慣に。読書を通じて新しい情報をインプットしたり、擬似体験を楽しんだりしていることが、時間の充実感につながっているのかもしれません。
もちろん、まだお酒のことを思い出す瞬間はありますが、「呑みたい」という気持ちはほとんどなく、無理に我慢しているわけでもありません。たまに「今呑んだらどうなるんだろう?」と想像することはありますが、またあの苦痛な生活に戻るのかと思うと、今の方がずっと満たされていると実感できます。
最近読んでいる 「そろそろ、お酒をやめようかな」 という本では、飲酒が脳に与える影響について説明されていました。飲酒はドーパミンの分泌を促進させ、楽しさを感じさせますが、その反動で脳が過剰なドーパミンを抑制したり感じにくくさせるようです(ダウンレギュレーション)。つまり、シラフの時間を楽しめなくなったり、鬱っぽくなったりするのだとか。私も経験あるあるなのでよくわかります。怖いですね。一方で断酒を始めると、脳は回復し始め、物事を楽しめるようになります。実際に今の私がまさにそうです。この変化を実感できるのは、本当に嬉しいことです。
日々に感謝し、小さな幸せを見つけることも、断酒生活を充実させるコツだと感じます。例えば、最近は毎週山登りをしていますが、登山の途中で綺麗な水を飲むという行為だけでも、世界的に見ればとても恵まれたことだと気づきます。日本の豊かな自然の中で過ごせることも、考えればとても贅沢で幸せなことです。
「足るを知る」という言葉がありますが、今の私はまさにその境地です。特別な変化がなくても、今あるものに目を向ければ、それだけで毎日が幸せに感じられるのではないでしょうか。
さて、今回の「断酒iOSアプリ制作」では、紙吹雪のアニメーションを制作してみました。お酒を飲まなかった日は、このアニメーションを表示させて少しでも達成感を演出できればと思います。
紙吹雪アニメーション1
ChatGPTに紙吹雪アニメーションを相談すると、意外にも簡単に実現できることがわかりました。
デザイン調整も何もない状態ですが、こんな感じでランダムな紙吹雪がループされます。
ソースコード
以下がソースコードです。あらかじめ、三角・四角・丸の画像を用意しておく必要があります。
func addConfettiAnimation() {
let emitterLayer = CAEmitterLayer()
.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
emitterLayer.emitterShape = .line
emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)
emitterLayer
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()
.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
cell.append(cell)
cells}
}
.emitterCells = cells
emitterLayer.layer.addSublayer(emitterLayer)
view}
以下のようにして呼び出します。
override func viewDidLoad() {
super.viewDidLoad()
.backgroundColor = .white
view()
addConfettiAnimation}
紙吹雪アニメーション2
パラメーターを変えてデザインを調整していきます。
ソースコード
func addConfettiAnimation() {
let emitterLayer = CAEmitterLayer()
.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
emitterLayer.emitterShape = .line
emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)
emitterLayer
// カラフルでまとまりのある色を指定
let colors: [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
UIColor]
// 細かい形状(サークルや四角など)
let shapes = ["circle", "square", "triangle"]
var cells: [CAEmitterCell] = []
for color in colors {
for shape in shapes {
let cell = CAEmitterCell()
.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
cell.append(cell)
cells}
}
.emitterCells = cells
emitterLayer.layer.addSublayer(emitterLayer)
view}
紙吹雪アニメーション3
画像素材を、丸・星・ダイアモンド型に変更すると可愛らしい雰囲気になりました。
ソースコード
func addConfettiAnimation() {
let emitterLayer = CAEmitterLayer()
.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
emitterLayer.emitterShape = .line
emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)
emitterLayer
// 達成感を感じさせる華やかな色
let colors: [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
UIColor]
// 細かい形状(サークルや四角など)
let shapes = ["circle", "star", "diamond"]
var cells: [CAEmitterCell] = []
for color in colors {
for shape in shapes {
let cell = CAEmitterCell()
.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
cell.append(cell)
cells}
}
.emitterCells = cells
emitterLayer.layer.addSublayer(emitterLayer)
view}
紙吹雪アニメーション4
さらに少し色を調整して、完成させました。
ソースコード
func addConfettiAnimation() {
let emitterLayer = CAEmitterLayer()
.emitterPosition = CGPoint(x: view.bounds.midX, y: -10)
emitterLayer.emitterShape = .line
emitterLayer.emitterSize = CGSize(width: view.bounds.size.width, height: 1)
emitterLayer
// 鮮やかでカラフルな色合い
let colors: [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) // 紫
UIColor]
// 細かい形状(サークルや四角など)
let shapes = ["circle", "star", "diamond"]
var cells: [CAEmitterCell] = []
for color in colors {
for shape in shapes {
let cell = CAEmitterCell()
.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
cell.append(cell)
cells}
}
.emitterCells = cells
emitterLayer.layer.addSublayer(emitterLayer)
view}
この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。 - https://github.com/aragig/ios_sample_dansyu
今回のサンプルアプリは、ConfettiSampleAppターゲットをビルドすると再現できます。
【開発23日目】カスタムプリセット画面
断酒を始めてから3週間が経ちました。振り返ってみると、たったの3週間とは思えないほど長く感じます。断酒を本当に習慣化するには、最低でも90日以上の継続が必要だと聞きます。そう考えると、少し不安になることもありますが、一日一日を積み重ねていくしかありませんね。この先も、断酒を続けていこうと思います。
さて、最近は断酒アプリ制作がなかなか進んでおりません。その理由は、以前お酒を飲んでいた時間が空いたことで、やりたいことが増えたからです。読書や山登り、そして最近では断捨離にも力を入れています。
この3週間で 『僕たちに、もうモノは必要ない。』 という本を2回も読んでしまうほど、とくに断捨離やミニマリズムには惹かれています。このジャンルの本の中では、一番納得感があり、共感できる内容でした。

前回の記事でも触れましたが、「足るを知る」という考え方に深く共感しています。本書では、便利なものをあえて手放し、少し不便を受け入れることで得られる豊かさについて書かれていました。例えば、著者がタオルをやめて手拭いに切り替えた話がありました。手拭いはタオルほどの吸水力はありませんが、乾きが早いというメリットがあります。そして、たまにタオルを手にした時、タオルのふんわりさに驚くほどのありがたさ、うれしさを感じるのだとか。確かにいつもタオルを使っていると、ありがたみを感じる機会はありませんよね。また、便利なものばかりを求めると、物は増え、掃除が大変になったり、買い替えの手間が増えたりして、結果として物に振り回されることになるのだと感じます。
「すでに事足りているのでは?」と自分に問いかける時間が、私にとっての断捨離なのかもしれません。これって、キャンプのノリで生きてる感じでなんか楽しそうです。バックパックに必要最低限の荷物を詰めて出かけるキャンプでは、当然リュックに入る荷物には限度がありますから。何かを諦めて、不便さを引き受けなければなりません。しばらく忘れてました、この感覚。少し不便になっても良いから、代用できるものは手放すことを試しています。さすがにタオルは使ってますが、登山などで便利なドライタオルを数枚発注しました(※また物が増えてしまっていますが、タオルの代替実験ということで良しとします!)。
処分したいものはすでに決まっているので、年内にどこまで進められるかですね。どうしてもゴミに出すのはもったいないと思うモノは、メルカリなどを活用して処分しているのですが、これもなかなか骨の折れる作業でして。少しずつではありますが、物に感謝しながら「どなたかに大切にされますように」と願いを込めて手放しています。
さて、断捨離のことを書き始めると止まらなくなりそうなので、そろそろこの辺で。今回の断酒アプリ制作では、飲酒量のカスタムプリセット画面を作成していきます。そうそう、私にとっては断酒という名の断捨離ほどの大きなものはありませんでしたね!

アプリの全体像
このアプリでは、飲酒量を記録するプリセットを管理する仕組みを提供しています。メイン画面でプリセットを一覧管理し、詳細画面でプリセットを作成・編集する流れを採用しています。シンプルながら拡張性の高い設計となっています。
MainViewController.swift: メイン画面の管理
このクラスは、アプリのメイン画面を担当します。ユーザーがプリセットをリスト形式で閲覧・編集するためのインターフェースを提供します。
- プリセットリストの表示: UITableViewを利用してアルコールプリセットを一覧表示。
- 新規追加と編集モード: ナビゲーションバーのボタンで、新規プリセットの追加とリストの並べ替えを切り替え。
- 詳細画面への遷移: プリセットを選択すると詳細画面に遷移します。
注目ポイント:
- setupTableViewメソッド: テーブルビューの初期設定を行います。
- toggleEditModeメソッド: 並べ替えモードのオン・オフを切り替えるボタン。
ソースコード
//
// MainViewController.swift
// AlcoholPresetSampleApp
//
// Created by Toshihiko Arai on 2024/12/12.
//
import UIKit
class MainViewController: UIViewController {
var tableView: UITableView!
var presets: [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),
AlcoholPreset]
override func viewDidLoad() {
super.viewDidLoad()
()
setupTableView()
setupNavigationBar}
private func setupTableView() {
= UITableView(frame: view.bounds, style: .plain)
tableView .delegate = self
tableView.dataSource = self
tableView.register(CustomTableViewCell.self, forCellReuseIdentifier: "CustomCell")
tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
tableView.addSubview(tableView)
view}
private func setupNavigationBar() {
let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped))
let editButton = UIBarButtonItem(title: "並べ替え", style: .plain, target: self, action: #selector(toggleEditMode))
.rightBarButtonItems = [addButton, editButton]
navigationItem}
private func showDetailViewController(for preset: AlcoholPreset?, index: Int?) {
let detailVC = DetailViewController()
.preset = preset
detailVC.index = index
detailVC.delegate = self
detailVC?.pushViewController(detailVC, animated: true)
navigationController}
@objc private func addButtonTapped() {
(for: nil, index: nil)
showDetailViewController}
@objc private func toggleEditMode() {
.setEditing(!tableView.isEditing, animated: true)
tableView.rightBarButtonItems?[1].title = tableView.isEditing ? "完了" : "並べ替え"
navigationItem}
}
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]
.configure(name: preset.name, alcoholPercentage: preset.alcoholPercentage, volume: preset.volume, pureAlcohol: preset.pureAlcohol)
cellreturn cell
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
.remove(at: indexPath.row)
presets.deleteRows(at: [indexPath], with: .automatic)
tableView}
}
}
extension MainViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let preset = presets[indexPath.row]
(for: preset, index: indexPath.row)
showDetailViewController}
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)
.insert(movedPreset, at: destinationIndexPath.row)
presets}
}
extension MainViewController: DetailViewControllerDelegate {
func didSavePreset(_ preset: AlcoholPreset, at index: Int?) {
if let index = index {
[index] = preset
presets} else {
.append(preset)
presets}
.reloadData()
tableView}
}
DetailViewController.swift: プリセットの詳細編集画面
このクラスは、アルコールプリセットの追加または編集を行う詳細画面です。
- 入力フィールドの表示とデータ入力: 名前、アルコール度数、量、純アルコール量を入力。
- 純アルコール量の自動計算: アルコール度数や量の入力値が変更されると、自動的に純アルコール量を計算。
- 保存ボタンの実装: 入力データを保存し、メイン画面に戻る。
注目ポイント:
- updatePureAlcoholFieldメソッド: 入力された値を基に純アルコール量を計算。
- saveButtonTappedメソッド: 入力内容をバリデーションし、データを保存。
//
// 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()
.backgroundColor = .white
view()
setupUI()
populateData()
updatePureAlcoholField
// テキストフィールドの値変更を監視
.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
alcoholPercentageTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
volumeTextField}
private func setupUI() {
// ラベルの共通設定
func setupLabel(_ label: UILabel, text: String) {
.text = text
label.textAlignment = .right
label.translatesAutoresizingMaskIntoConstraints = false
label}
// 各ラベルの設定
(nameLabel, text: "名前:")
setupLabel(alcoholLabel, text: "アルコール度数:")
setupLabel(volumeLabel, text: "量:")
setupLabel(pureAlcoholLabel, text: "純アルコール量:")
setupLabel
// 各テキストフィールドと単位ラベルの設定
.placeholder = "酒の名前"
nameTextField.borderStyle = .roundedRect
nameTextField.translatesAutoresizingMaskIntoConstraints = false
nameTextField
.placeholder = "例: 5.0"
alcoholPercentageTextField.keyboardType = .decimalPad
alcoholPercentageTextField.borderStyle = .roundedRect
alcoholPercentageTextField.translatesAutoresizingMaskIntoConstraints = false
alcoholPercentageTextField
.text = "%"
alcoholUnitLabel.translatesAutoresizingMaskIntoConstraints = false
alcoholUnitLabel
.placeholder = "例: 500"
volumeTextField.keyboardType = .decimalPad
volumeTextField.borderStyle = .roundedRect
volumeTextField.translatesAutoresizingMaskIntoConstraints = false
volumeTextField
.text = "ml"
volumeUnitLabel.translatesAutoresizingMaskIntoConstraints = false
volumeUnitLabel
.placeholder = ""
pureAlcoholTextField.keyboardType = .decimalPad
pureAlcoholTextField.borderStyle = .roundedRect
pureAlcoholTextField.isEnabled = false
pureAlcoholTextField.translatesAutoresizingMaskIntoConstraints = false
pureAlcoholTextField
.text = "ml"
pureAlcoholUnitLabel.translatesAutoresizingMaskIntoConstraints = false
pureAlcoholUnitLabel
.setTitle("保存", for: .normal)
saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
saveButton.translatesAutoresizingMaskIntoConstraints = false
saveButton
// ビューに追加
.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)
view
// 定数設定
let labelWidth: CGFloat = 120
let fieldSpacing: CGFloat = 10
// Auto Layout
.activate([
NSLayoutConstraint// 名前ラベルとフィールド
.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
nameLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
nameLabel.widthAnchor.constraint(equalToConstant: labelWidth),
nameLabel
.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor),
nameTextField.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: fieldSpacing),
nameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
nameTextField
// アルコール度数ラベルとフィールド
.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20),
alcoholLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
alcoholLabel.widthAnchor.constraint(equalToConstant: labelWidth),
alcoholLabel
.centerYAnchor.constraint(equalTo: alcoholLabel.centerYAnchor),
alcoholPercentageTextField.leadingAnchor.constraint(equalTo: alcoholLabel.trailingAnchor, constant: fieldSpacing),
alcoholPercentageTextField.widthAnchor.constraint(equalToConstant: 100),
alcoholPercentageTextField
.centerYAnchor.constraint(equalTo: alcoholPercentageTextField.centerYAnchor),
alcoholUnitLabel.leadingAnchor.constraint(equalTo: alcoholPercentageTextField.trailingAnchor, constant: fieldSpacing),
alcoholUnitLabel
// 量ラベルとフィールド
.topAnchor.constraint(equalTo: alcoholLabel.bottomAnchor, constant: 20),
volumeLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
volumeLabel.widthAnchor.constraint(equalToConstant: labelWidth),
volumeLabel
.centerYAnchor.constraint(equalTo: volumeLabel.centerYAnchor),
volumeTextField.leadingAnchor.constraint(equalTo: volumeLabel.trailingAnchor, constant: fieldSpacing),
volumeTextField.widthAnchor.constraint(equalToConstant: 100),
volumeTextField
.centerYAnchor.constraint(equalTo: volumeTextField.centerYAnchor),
volumeUnitLabel.leadingAnchor.constraint(equalTo: volumeTextField.trailingAnchor, constant: fieldSpacing),
volumeUnitLabel
// 純アルコール量ラベルとフィールド
.topAnchor.constraint(equalTo: volumeLabel.bottomAnchor, constant: 20),
pureAlcoholLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
pureAlcoholLabel.widthAnchor.constraint(equalToConstant: labelWidth),
pureAlcoholLabel
.centerYAnchor.constraint(equalTo: pureAlcoholLabel.centerYAnchor),
pureAlcoholTextField.leadingAnchor.constraint(equalTo: pureAlcoholLabel.trailingAnchor, constant: fieldSpacing),
pureAlcoholTextField.widthAnchor.constraint(equalToConstant: 100),
pureAlcoholTextField
.centerYAnchor.constraint(equalTo: pureAlcoholTextField.centerYAnchor),
pureAlcoholUnitLabel.leadingAnchor.constraint(equalTo: pureAlcoholTextField.trailingAnchor, constant: fieldSpacing),
pureAlcoholUnitLabel
// 保存ボタン
.topAnchor.constraint(equalTo: pureAlcoholTextField.bottomAnchor, constant: 40),
saveButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
saveButton])
}
private func populateData() {
// データをUIに反映
if let preset = preset {
.text = preset.name
nameTextField.text = "\(preset.alcoholPercentage)"
alcoholPercentageTextField.text = "\(preset.volume)"
volumeTextField}
}
@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 {
// 入力が不完全な場合は処理を中断
("入力値が不正です!")
printreturn
}
// 新しいプリセットを作成
let newPreset = AlcoholPreset(name: name, alcoholPercentage: alcoholPercentage, volume: volume)
// デリゲートを通じてデータを戻す
?.didSavePreset(newPreset, at: index)
delegate?.popViewController(animated: true)
navigationController}
@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 {
.text = ""
pureAlcoholTextFieldreturn
}
let preset = AlcoholPreset(name: "", alcoholPercentage: alcoholPercentage, volume: volume)
.text = "\(preset.pureAlcohol)"
pureAlcoholTextField}
}
CustomTableViewCell.swift: カスタムセルのデザイン
アルコールプリセットを表示するためのカスタムデザインセルを実装しています。
主な機能:
- ラベル配置の工夫: 名前、アルコール度数、量、純アルコール量を見やすく整列。
- データの設定: configureメソッドで各セルにデータを適用。
注目ポイント:
- Auto Layoutを活用した動的なレイアウト設定。
- ラベルのフォントサイズや配置を統一。
ソースコード
// 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) {
.text = text
label.textAlignment = .right
label.font = UIFont.systemFont(ofSize: 14, weight: .medium)
label.translatesAutoresizingMaskIntoConstraints = false
label}
(alcoholKeyLabel, text: "アルコール度数:")
configureKeyLabel(volumeKeyLabel, text: "量:")
configureKeyLabel(pureAlcoholKeyLabel, text: "純アルコール:")
configureKeyLabel
// 値ラベル共通設定
func configureValueLabel(_ label: UILabel) {
.textAlignment = .left
label.font = UIFont.systemFont(ofSize: 14)
label.translatesAutoresizingMaskIntoConstraints = false
label}
.font = UIFont.systemFont(ofSize: 16, weight: .bold)
nameValueLabel.translatesAutoresizingMaskIntoConstraints = false
nameValueLabel
(alcoholValueLabel)
configureValueLabel(volumeValueLabel)
configureValueLabel(pureAlcoholValueLabel)
configureValueLabel
// ラベルをコンテンツビューに追加
.addSubview(nameValueLabel)
contentView.addSubview(alcoholKeyLabel)
contentView.addSubview(alcoholValueLabel)
contentView.addSubview(volumeKeyLabel)
contentView.addSubview(volumeValueLabel)
contentView.addSubview(pureAlcoholKeyLabel)
contentView.addSubview(pureAlcoholValueLabel)
contentView
// Auto Layout 制約
.activate([
NSLayoutConstraint// 名前ラベル
.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
nameValueLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
nameValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),
nameValueLabel
// アルコール度数ラベル
.topAnchor.constraint(equalTo: nameValueLabel.bottomAnchor, constant: 15),
alcoholKeyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
alcoholKeyLabel.widthAnchor.constraint(equalToConstant: 120),
alcoholKeyLabel
.topAnchor.constraint(equalTo: alcoholKeyLabel.topAnchor),
alcoholValueLabel.leadingAnchor.constraint(equalTo: alcoholKeyLabel.trailingAnchor, constant: 10),
alcoholValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),
alcoholValueLabel
// 量ラベル
.topAnchor.constraint(equalTo: alcoholKeyLabel.bottomAnchor, constant: 10),
volumeKeyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
volumeKeyLabel.widthAnchor.constraint(equalToConstant: 120),
volumeKeyLabel
.topAnchor.constraint(equalTo: volumeKeyLabel.topAnchor),
volumeValueLabel.leadingAnchor.constraint(equalTo: volumeKeyLabel.trailingAnchor, constant: 10),
volumeValueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),
volumeValueLabel
// 純アルコール量ラベル
.topAnchor.constraint(equalTo: volumeKeyLabel.bottomAnchor, constant: 10),
pureAlcoholKeyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
pureAlcoholKeyLabel.widthAnchor.constraint(equalToConstant: 120),
pureAlcoholKeyLabel
.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)
pureAlcoholValueLabel])
}
// データを設定するためのメソッド
func configure(name: String, alcoholPercentage: Double, volume: Int, pureAlcohol: Double) {
.text = name
nameValueLabel.text = "\(String(format: "%.1f", alcoholPercentage))%"
alcoholValueLabel.text = "\(volume)ml"
volumeValueLabel.text = "\(pureAlcohol)ml"
pureAlcoholValueLabel}
}
AlcoholPreset.swift: プリセットデータモデル
アルコールプリセットのデータ構造を定義するモデルクラスです。
- プロパティ: 名前、アルコール度数、量を保持。
- 純アルコール量の計算: アルコール度数と量から純アルコール量を計算し、小数点以下1桁で丸めます。
注目ポイント:
- pureAlcoholプロパティ: 必要なときに計算されるため、値の整合性が保たれます。
ソースコード
//
// 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ターゲットをビルドすると再現できます。