【断酒iOSアプリ制作】カスタムプリセット画面 (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] = [
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: プリセットの詳細編集画面
このクラスは、アルコールプリセットの追加または編集を行う詳細画面です。
主な機能:
- 入力フィールドの表示とデータ入力: 名前、アルコール度数、量、純アルコール量を入力。
- 純アルコール量の自動計算: アルコール度数や量の入力値が変更されると、自動的に純アルコール量を計算。
- 保存ボタンの実装: 入力データを保存し、メイン画面に戻る。
注目ポイント:
- 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()
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: カスタムセルのデザイン
アルコールプリセットを表示するためのカスタムデザインセルを実装しています。
主な機能:
- ラベル配置の工夫: 名前、アルコール度数、量、純アルコール量を見やすく整列。
- データの設定: 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) {
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: プリセットデータモデル
アルコールプリセットのデータ構造を定義するモデルクラスです。
主な機能:
- プロパティ: 名前、アルコール度数、量を保持。
- 純アルコール量の計算: アルコール度数と量から純アルコール量を計算し、小数点以下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ターゲットをビルドすると再現できます。