【第2部】SwiftUIで作る日本の気温マップアプリ - UI実装とコンポーネント編
前回の第1部では、アプリの全体設計とMVVMアーキテクチャについて解説しました。第2部では、実際のUI実装とコンポーネント設計について詳しく説明します。
## 温度による視覚的な表現
このアプリの最大の特徴は、温度に応じた動的な視覚表現です。温度を4つのカテゴリーに分類し、それぞれに適切な色とグラデーションを適用しています。
### 温度分類システム
RegionDataのExtensionとして実装されている温度分類システム:
extension RegionData { var temperatureGradient: LinearGradient { switch currentTemp { case ...20: return LinearGradient(colors: [ Color(red: 0.31, green: 0.76, blue: 0.97), Color(red: 0.16, green: 0.71, blue: 0.96) ], startPoint: .topLeading, endPoint: .bottomTrailing) case 21...28: return LinearGradient(colors: [ Color(red: 1.0, green: 0.72, blue: 0.30), Color(red: 1.0, green: 0.60, blue: 0.0) ], startPoint: .topLeading, endPoint: .bottomTrailing) case 29...35: return LinearGradient(colors: [ Color(red: 1.0, green: 0.44, blue: 0.26), Color(red: 0.96, green: 0.26, blue: 0.21) ], startPoint: .topLeading, endPoint: .bottomTrailing) case 36...: return LinearGradient(colors: [ Color(red: 0.91, green: 0.12, blue: 0.39), Color(red: 0.76, green: 0.10, blue: 0.36) ], startPoint: .topLeading, endPoint: .bottomTrailing) default: return LinearGradient(colors: [Color.gray], startPoint: .topLeading, endPoint: .bottomTrailing) } } }
### 温度ステータスの文字表現
var statusText: String { switch currentTemp { case ...20: return "涼しい" case 21...28: return "暖かい" case 29...35: return "暑い" case 36...: return "猛暑" default: return "不明" } } var statusEmoji: String { switch currentTemp { case ...20: return "🔵" case 21...28: return "🟡" case 29...35: return "🔴" case 36...: return "🟣" default: return "⚪" } }
### 実用的なアドバイス機能
var recommendation: String { switch currentTemp { case ...20: return "長袖がおすすめです" case 21...28: return "半袖で快適に過ごせます" case 29...35: return "水分補給をこまめに行いましょう" case 36...: return "外出時は十分な熱中症対策を" default: return "気温に応じた服装を" } } var healthAdvice: String { switch currentTemp { case ...20: return "涼しい気候です。軽い運動や散歩に適しています。" case 21...28: return "過ごしやすい気温です。屋外活動に最適な時期です。" case 29...35: return "暑い日です。こまめな水分補給と適度な休憩を心がけましょう。" case 36...: return "猛暑日です。外出は控えめに、エアコンを適切に使用し、熱中症対策を万全にしてください。" default: return "気温に応じた対策を心がけましょう。" } }
## コンポーネントベースの設計
UIを再利用可能なコンポーネントに分割することで、保守性と可読性を向上させています。
### StatCard(統計カード)コンポーネント
統計情報を表示する基本的なカードコンポーネント:
struct StatCard: View { let value: String let label: String var body: some View { VStack(spacing: 8) { Text(value) .font(.title2) .fontWeight(.bold) .foregroundColor(Color(red: 0.30, green: 0.69, blue: 0.31)) Text(label) .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 15) .background(Color(UIColor.systemGray6)) .cornerRadius(10) } }
**設計のポイント** : - **プロパティ駆動** : valueとlabelを外部から注入 - **一貫したスタイリング** : 統一されたフォントと色使い - **柔軟性** : frameのmaxWidthで親ビューに合わせて伸縮
### TemperatureRowView(温度行表示)
テーブル内の各行を表現するコンポーネント:
struct TemperatureRowView: View { let region: RegionData let isSelected: Bool var body: some View { HStack { Text(region.name) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.primary) .frame(maxWidth: .infinity, alignment: .leading) Text("\(region.currentTemp)℃") .font(.system(size: 16, weight: .bold)) .foregroundColor(.white) .frame(width: 70, height: 35) .background(region.temperatureGradient) .cornerRadius(18) Text(region.statusText) .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(region.temperatureColor) .cornerRadius(12) .frame(maxWidth: .infinity) Text(region.feelsLike) .font(.system(size: 12)) .foregroundColor(.secondary) .frame(maxWidth: .infinity) } .padding() .background(isSelected ? Color(red: 0.91, green: 0.96, blue: 0.91) : Color.clear) .overlay( Rectangle() .fill(isSelected ? Color(red: 0.30, green: 0.69, blue: 0.31) : Color.clear) .frame(width: 4) .clipped(), alignment: .leading ) } }
**特徴** : - **選択状態の視覚表現** : 背景色と左端のアクセントで選択状態を明示 - **レスポンシブデザイン** : HStackとframeを組み合わせた柔軟なレイアウト - **温度表示の強調** : グラデーション背景で温度を視覚的に強調
### RegionDetailView(地域詳細表示)
選択された地域の詳細情報を表示するコンポーネント:
struct RegionDetailView: View { let region: RegionData var body: some View { VStack(alignment: .leading, spacing: 20) { // ヘッダー HStack { Text("\(region.statusEmoji) \(region.name)地方") .font(.title2) .fontWeight(.bold) .foregroundColor(Color(red: 0.30, green: 0.69, blue: 0.31)) Spacer() } // 気温情報 VStack(alignment: .leading, spacing: 15) { HStack { Text("🌡️ 現在の最高気温:") .font(.body) .fontWeight(.semibold) Text("\(region.currentTemp)℃") .font(.title3) .fontWeight(.bold) .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(region.temperatureGradient) .cornerRadius(12) } DetailRow(icon: "☀️", title: "今日の天気:", content: region.currentComment) DetailRow(icon: "👕", title: "服装の推奨:", content: region.recommendation) DetailRow(icon: "🤗", title: "体感:", content: region.feelsLike) DetailRow(icon: "📊", title: "温度区分:", content: region.statusText) } .padding() .background(Color(UIColor.systemBackground)) .cornerRadius(12) // アドバイス VStack(alignment: .leading, spacing: 10) { Text("💡 アドバイス:") .font(.headline) .foregroundColor(.primary) Text(region.healthAdvice) .font(.body) .foregroundColor(.secondary) } .padding() .background(Color(UIColor.systemBlue).opacity(0.1)) .cornerRadius(12) .overlay( Rectangle() .fill(Color(red: 0.30, green: 0.69, blue: 0.31)) .frame(width: 4) .clipped(), alignment: .leading ) } .padding() .background(Color(UIColor.systemGray6)) .cornerRadius(15) } }
### DetailRow(詳細情報行)
詳細情報の各項目を表示する小さなコンポーネント:
struct DetailRow: View { let icon: String let title: String let content: String var body: some View { HStack(alignment: .top, spacing: 8) { Text(icon) .font(.body) Text(title) .font(.body) .fontWeight(.semibold) .foregroundColor(.primary) Text(content) .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.leading) Spacer() } } }
## レイアウト設計
### メインビューの構造
struct ContentView: View { @StateObject private var dataManager = TemperatureDataManager() var body: some View { NavigationView { VStack(spacing: 0) { // ヘッダー HeaderView() ScrollView { VStack(spacing: 20) { // 統計情報 StatisticsView(stats: dataManager.getStats()) // 温度区分の凡例 LegendView() // 温度テーブル TemperatureTableView( regions: dataManager.regions, selectedRegion: $dataManager.selectedRegion ) // コントロールボタン ControlButtonsView(dataManager: dataManager) // 最終更新時刻 LastUpdateView(lastUpdate: dataManager.lastUpdate) // 詳細情報パネル if let selectedRegion = dataManager.selectedRegion { RegionDetailView(region: selectedRegion) } else { InstructionView() } } .padding() } .background(Color(UIColor.systemGroupedBackground)) } .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) } }
**レイアウトの特徴** : - **ScrollView** : 縦スクロール可能な全体構造 - **VStack** : 各セクションの垂直配置 - **条件付き表示** : 選択状態に応じた動的表示
### 統計情報の表示
struct StatisticsView: View { let stats: TemperatureStats var body: some View { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 15) { StatCard(value: "\(stats.average)℃", label: "平均気温") StatCard(value: "\(stats.max)℃", label: "最高気温") StatCard(value: "\(stats.min)℃", label: "最低気温") StatCard(value: "\(stats.hotRegions)地域", label: "猛暑地域") } .padding() .background(Color(UIColor.systemBackground)) .cornerRadius(15) } }
**LazyVGridの活用** : - **効率的な描画** : 必要な時のみ描画される遅延読み込み - **柔軟なグリッド** : .flexible()による自動サイズ調整 - **一貫したスペーシング** : 統一された間隔設定
### ヘッダーコンポーネント
struct HeaderView: View { var body: some View { VStack(spacing: 8) { Text("🌡️ 日本の気温マップ") .font(.largeTitle) .fontWeight(.bold) .foregroundColor(.white) Text("地方別気温情報一覧") .font(.subheadline) .foregroundColor(.white.opacity(0.9)) } .frame(maxWidth: .infinity) .padding(.vertical, 30) .background( LinearGradient( colors: [Color(red: 0.30, green: 0.69, blue: 0.31), Color(red: 0.27, green: 0.63, blue: 0.29)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) } }
## データ管理とビジネスロジック
### ソート機能の実装
#### 温度順ソート
func sortByTemperature() { regions.sort { $0.currentTemp > $1.currentTemp } sortOrder = .temperature }
#### 地域順ソート
func sortByRegion() { let regionOrder = ["北海道", "東北", "関東", "中部", "関西", "中国", "四国", "九州", "沖縄"] regions.sort { region1, region2 in let index1 = regionOrder.firstIndex(of: region1.name) ?? 0 let index2 = regionOrder.firstIndex(of: region2.name) ?? 0 return index1 < index2 } sortOrder = .geographic }
### データ更新機能
func updateAllTemperatures() { for i in 0..<regions.count { regions[i].updateTemperature() } lastUpdate = Date() }
**更新処理の流れ** : 1. 各地域の`updateTemperature()`メソッド呼び出し 2. `lastUpdate`の更新 3. `@Published`による自動UI更新
### 統計情報の自動計算
func getStats() -> TemperatureStats { let temps = regions.map { $0.currentTemp } let average = temps.reduce(0, +) / temps.count let max = temps.max() ?? 0 let min = temps.min() ?? 0 let hotRegions = temps.filter { $0 > 35 }.count return TemperatureStats(average: average, max: max, min: min, hotRegions: hotRegions) }
**統計計算の特徴** : - **関数型アプローチ** : map、reduce、filterの活用 - **安全な演算** : nil coalescing演算子による安全性確保 - **効率性** : 一度の配列走査で必要な情報を収集
## まとめ
第2部では、SwiftUIを使った実際のUI実装とコンポーネント設計について解説しました。
**主要なポイント** : - 温度による動的な視覚表現の実装 - 再利用可能なコンポーネント設計 - LazyVGridを活用したレスポンシブレイアウト - Extensionを使った表示ロジックの分離 - 効率的なデータ操作とソート機能
次回の第3部では、包括的なテスト実装と、アプリの技術的特徴について詳しく解説します。特に、単体テスト、統合テスト、パフォーマンステストの実装方法と、SwiftUIアプリ開発のベストプラクティスを中心に説明します。
* * *
**シリーズ記事**
* 第1部] 設計と[アーキテクチャ編
* 第2部] UI実装と[コンポーネント編(今回)
* [第3部] テストと技術特徴編(次回公開予定)
ランキング参加中プログラミング