5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

WidgetKitで天気予報アプリ作ってみた〜View実装編〜

Posted at

投稿の経緯

この記事は、前回投稿したWidgetKitで天気予報アプリ作ってみたシリーズ~タイムライン作成編~の続編です。
今回はいよいよViewを実装していきます。

前回の記事を見てない人は先に↓こちら↓を確認してください。

環境

Swift 5.5
Xcode 13.2.1

サンプルプロジェクト

GitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki-222/WeatherWidget
QR_615642.png

Viewで表示する内容

image.png
画像のように各ブロックごとに分割して開発していきます。周りの余白は16ではみ出す場合はclipped()します。
Viewで表示する内容は以下の通り。

青ブロック

  • 現在の気温
  • 現在時刻
  • 地点名

赤ブロック

  • 気圧グラフ

緑ブロック

  • 時刻テキスト
  • 天気アイコン
  • 気温

表示するデータは前回作成した「EntryModel」から取得して使います。

MediumWidgetEntryModel.swift
import WidgetKit
import SwiftUI

struct MediumWidgetEntryModel: TimelineEntry {
    let date: Date
    var hourlyWeathers: [Hourly]
    var currentLocation: String?
    var weatherIcons: [String]
    var timePeriodTexts: [String]
    var temperatureTexts: [String]
    var hourlyPressures: [Double]
    
    init(currentDate: Date, hourlyWeathers: [Hourly], currentLocation: String?) {
        self.date = currentDate
        self.currentLocation = currentLocation
        
        self.hourlyWeathers = []
        self.weatherIcons = []
        self.timePeriodTexts = []
        self.temperatureTexts = []
        self.hourlyPressures = []
        
        for index in 0..<24 {
            self.hourlyWeathers.append(hourlyWeathers[index])
            self.hourlyPressures.append(hourlyWeathers[index].pressure)
            
            let timePeriodText = getTimePeriodText(hourlyWeather: hourlyWeathers[index])
            self.timePeriodTexts.append(timePeriodText)
            
            let weather = hourlyWeathers[index].weather[0]
            if let weatherIconName = getWeatherIconName(weather: weather) {
                self.weatherIcons.append(weatherIconName)
            }
            
            let temp = String(format: "%0.0f", hourlyWeathers[index].temp)
            self.temperatureTexts.append(temp)
        }
    }
    
    func getTimePeriodText(hourlyWeather: Hourly) -> String {
        let date = Date(timeIntervalSince1970: hourlyWeather.dt)
        let dateString = DateFormatHelper.shared.formatToHHmm(date: date)
        var timePeriodText: String
        
        if dateString == "00:00" {
            timePeriodText = "0"
        } else if dateString == "03:00" {
            timePeriodText = "3"
        } else if dateString == "06:00" {
            timePeriodText = "6"
        } else if dateString == "09:00" {
            timePeriodText = "9"
        } else if dateString == "12:00" {
            timePeriodText = "12"
        } else if dateString == "15:00" {
            timePeriodText = "15"
        } else if dateString == "18:00" {
            timePeriodText = "18"
        } else if dateString == "21:00" {
            timePeriodText = "21"
        } else {
            timePeriodText = "・"
        }
        
        return timePeriodText
    }
    
    func getWeatherIconName(weather: Weather) -> String? {
        var wetherName: String
        
        switch weather.main {
        case "Clear":
            wetherName = WeatherTypeTranslator.translate(type: .clear)
        case "Clouds":
            wetherName = WeatherTypeTranslator.translate(type: .clouds)
        case "Rain":
            wetherName = WeatherTypeTranslator.translate(type: .rain)
        case "Snow":
            wetherName = WeatherTypeTranslator.translate(type: .snow)
        default:
            return nil
        }
        
        return wetherName
    }    
}

このタイミングで「EntryModel」の値をインスタンス化しておきましょう。

MediumWidgetView.swift
struct MediumWidgetView: View {
    @Environment(\.colorScheme) var colorScheme
    var entry: MediumWidgetProvider.Entry
    
    var body: some View {
        let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date)
        let hourlyWeathers = entry.hourlyWeathers
        let timePeriodTexts = entry.timePeriodTexts
        let weatherIcons = entry.weatherIcons
        let temperatureTexts = entry.temperatureTexts
        let hourlyPressures = entry.hourlyPressures
        
        GeometryReader { geometry in
            let geometryWidth = geometry.size.width
            let geometryHeight = geometry.size.height
            let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth
            VStack {
                Spacer().frame(height: 16)
                VStack(spacing: 0) {
                    // 現在気温、現在時刻、地点名(青ブロック)
                    HStack {
                        Spacer()
                        HStack(spacing: 0) {
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 6)
                        .background(Color.blue)
                        Spacer()
                    }
                    // グラフ(赤ブロック)
                    HStack {
                        Spacer()
                        HStack {
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        .background(Color.red)
                        Spacer()
                    }
                    // 時刻、天気アイコン、気温(緑ブロック)
                    HStack {
                        Spacer()
                        HStack(alignment: .top, spacing: 0) {
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        .background(Color.green)
                        Spacer()
                    }
                }
            }
        }
        .background(ColorManager.background)
    }
}

struct MediumWidgetView_Previews: PreviewProvider {
    static var previews: some View {
        let entry = MediumWidgetEntryModel(
            currentDate: Date(timeIntervalSince1970: 1644048000),
            hourlyWeathers: MockHourly.data,
            currentLocation: "世田谷区"
        )
        
        Group {
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .light)
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .dark)
        }
    }
}
  • currentDate -> 現在時刻
  • hourlyWeathers -> 24時間分の天気データ
  • timePeriodTexts -> 時刻テキスト
  • weatherIcons -> 天気アイコン
  • temperatureTexts -> 気温
  • hourlyPressures -> 気圧

ViewのwidthheightGeometryReaderで取得しており、こちらもインスタンス化してViewの実装を進めます。
DateFormatHelperの中身はこちら。

DateFormatHelper.swift
import Foundation

final class DateFormatHelper {
    static let shared = DateFormatHelper()
    
    private let hourAndMinutesFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "Asia/Tokyo")
        formatter.dateFormat = "HH:mm"
        return formatter
    }()
    
    private let hourFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "Asia/Tokyo")
        formatter.dateFormat = "HH"
        return formatter
    }()
    
    func formatToHHmm(date: Date) -> String {
        return hourAndMinutesFormatter.string(from: date)
    }
    
    func formatToHH(date: Date) -> String {
        return hourFormatter.string(from: date)
    }
}

View完成イメージ

スクリーンショット 2022-02-22 17.49.45.png

現在の気温、現在時、地点名の実装(青ブロック)

MediumWidgetView.swift
struct MediumWidgetView: View {
    @Environment(\.colorScheme) var colorScheme
    var entry: MediumWidgetProvider.Entry
    
    var body: some View {
        let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date)
        let hourlyWeathers = entry.hourlyWeathers
        let timePeriodTexts = entry.timePeriodTexts
        let weatherIcons = entry.weatherIcons
        let temperatureTexts = entry.temperatureTexts
        let hourlyPressures = entry.hourlyPressures
        
        GeometryReader { geometry in
            let geometryWidth = geometry.size.width
            let geometryHeight = geometry.size.height
            let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth
            VStack {
                Spacer().frame(height: 16)
                VStack(spacing: 0) {
                    // 現在気温、現在時刻、地点名(青ブロック)
                    HStack {
                        Spacer()
                        HStack(spacing: 0) {
                            HStack(spacing: 0) {
                                Text(String(format: "%0.0f", hourlyWeathers[0].temp))
                                    .foregroundColor(ColorManager.font)
                                    .font(.system(size: 30, weight: .semibold))
                                    .offset(x: 5)
                                    .fixedSize(horizontal: true, vertical: true)
                                Text("℃" + " \(currentDate):00" +  "現在")
                                    .foregroundColor(ColorManager.font)
                                    .font(.system(size: 14, weight: .medium))
                                    .offset(x: 5)
                                    .fixedSize(horizontal: true, vertical: true)
                                    .frame(height: geometryHeight / 6, alignment: .bottom)
                            }
                            .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .leading)
                            if let location = entry.currentLocation {
                                Text(location)
                                    .foregroundColor(ColorManager.font)
                                    .font(.system(size: 14, weight: .medium))
                                    .offset(x: -5)
                                    .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .bottomTrailing)
                            }
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 6)
                        Spacer()
                    }
                    // グラフ(赤ブロック)
                    HStack {
                        Spacer()
                        HStack {
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        .background(Color.red)
                        Spacer()
                    }
                    // 時刻、天気アイコン、気温(緑ブロック)
                    HStack {
                        Spacer()
                        HStack(alignment: .top, spacing: 0) {
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        .background(Color.green)
                        Spacer()
                    }
                }
            }
        }
        .background(ColorManager.background)
    }
}

struct MediumWidgetView_Previews: PreviewProvider {
    static var previews: some View {
        let entry = MediumWidgetEntryModel(
            currentDate: Date(timeIntervalSince1970: 1644048000),
            hourlyWeathers: MockHourly.data,
            currentLocation: "世田谷区"
        )
        
        Group {
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .light)
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .dark)
        }
    }
}

色はColorManagerで管理して、ダークモードにも対応しています。

ColorManager.swift
struct ColorManager {
    static let font = Color("font_color")
    static let background = Color("background_color")
    static let graph = Color("graph_color")
    static let graphBackground = Color("graphbackground_color")
}

これで、現在の気温、現在時、地点名の実装ができました。特に難しいことはしていないと思うので説明はしません(笑)

現在の気温、現在時、地点名を実装後のプレビュー

スクリーンショット 2022-02-22 16.49.12.png

気圧グラフの実装(赤ブロック)

MediumWidgetView.swift
import WidgetKit
import SwiftUI

struct PressureGraphPoint: Identifiable {
    var id = UUID()
    var points: [CGPoint] = []
}

struct MediumWidgetView: View {
    @Environment(\.colorScheme) var colorScheme
    var entry: MediumWidgetProvider.Entry
    
    var body: some View {
        let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date)
        let hourlyWeathers = entry.hourlyWeathers
        let timePeriodTexts = entry.timePeriodTexts
        let weatherIcons = entry.weatherIcons
        let temperatureTexts = entry.temperatureTexts
        let hourlyPressures = entry.hourlyPressures
        
        GeometryReader { geometry in
            let geometryWidth = geometry.size.width
            let geometryHeight = geometry.size.height
            let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth
            VStack {
                Spacer().frame(height: 16)
                VStack(spacing: 0) {
                    // 現在気温、現在時刻、地点名(青ブロック)
                    HStack {
                        Spacer()
                        HStack(spacing: 0) {
                           // 省略
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 6)
                        Spacer()
                    }
                    // グラフ(赤ブロック)
                    HStack {
                        Spacer()
                        HStack {
                            GeometryReader { graphGeometry in
                                let graphGeometryWidth = graphGeometry.size.width
                                let graphGeometryHeight = graphGeometry.size.height
                                let graphBackLineStartPoint = (widthPerHour * 0.5)
                                let graphBackLineEndPoint = graphGeometryWidth - graphBackLineStartPoint
                                let pressureGraphPoints = getPressureGraphPoints(hourlyPressures: hourlyPressures, width: graphGeometryWidth, height: graphGeometryHeight)
                                ZStack {
                                    // 背景ライン
                                    Path { path in
                                        path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y:3))
                                        path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: 3))
                                        path.move(to: CGPoint(x: graphBackLineStartPoint, y: graphGeometryHeight / 2))
                                        path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight / 2))
                                        path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y: graphGeometryHeight - 3))
                                        path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight - 3))
                                    }
                                    .stroke(ColorManager.graphBackground, lineWidth: 1)
                                    // 気圧グラフ
                                    ForEach(pressureGraphPoints) { pressureGraphPoint in
                                        Path { path in
                                            path.move(to: pressureGraphPoint.points[0])
                                            for index in 1..<pressureGraphPoint.points.count {
                                                path.addLine(to: pressureGraphPoint.points[index])
                                            }
                                        }
                                        .stroke(ColorManager.graph, lineWidth: 3)
                                        .offset(x: widthPerHour * 0.5)
                                        .clipped()
                                    }
                                    Text(String(format: "%0.0f", hourlyPressures[0] + 15))
                                        .font(.system(size: 6, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .topLeading)
                                    Text(String(format: "%0.0f", hourlyPressures[0]))
                                        .font(.system(size: 6, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .offset(y: 5)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .leading)
                                    Text(String(format: "%0.0f", hourlyPressures[0] - 15))
                                        .font(.system(size: 6, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomLeading)
                                    Text("hpa")
                                        .font(.system(size: 8, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .offset(x: -4, y: -4)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomTrailing)
                                }
                            }
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        Spacer()
                    }
                    // 時刻、天気アイコン、気温(緑ブロック)
                    HStack {
                        Spacer()
                        HStack(alignment: .top, spacing: 0) {
                            
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        .background(Color.green)
                        Spacer()
                    }
                }
            }
        }
        .background(ColorManager.background)
    }
    
    private func getPressureGraphPoints(hourlyPressures: [Double], width: CGFloat, height: CGFloat) -> [PressureGraphPoint] {
        let currentPressure = hourlyPressures[0]
        var pressureGraphPoints: [PressureGraphPoint] = []
        var tempPressurePoint = PressureGraphPoint()
        
        hourlyPressures.enumerated().forEach { index, hourlyPressure  in
            let pressureGraphPointWidth = (width / 24) * CGFloat(index) // 各時刻のグラフ描画のx軸
            
            let heightPerHpa = height / 30 
            let maxHpa = currentPressure + 15
            let diffPressure = maxHpa - hourlyPressure
            let pressureGraphPointHeight = diffPressure * heightPerHpa// 各時刻のグラフ描画のy軸
            let points = CGPoint(x: pressureGraphPointWidth, y: pressureGraphPointHeight)
            
            tempPressurePoint.points.append(points)
        }
        
        pressureGraphPoints.append(tempPressurePoint)
        
        return pressureGraphPoints
    }
}

struct MediumWidgetView_Previews: PreviewProvider {
    static var previews: some View {
        let entry = MediumWidgetEntryModel(
            currentDate: Date(timeIntervalSince1970: 1644048000),
            hourlyWeathers: MockHourly.data,
            currentLocation: "世田谷区"
        )
        
        Group {
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .light)
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .dark)
        }
    }
}

気圧グラフの背景ラインと気圧グラフはPathを用いて描画します。Pathで描画するコードをあまり複雑にしたくなかったので、getPressureGraphPointsに、新たに追加したGeometryReaderで取得したwidthとheightを渡し、PressureGraphPoint型のCGPintの配列を返しています。

getPressureGraphPointsの以下の部分で気圧グラフ24時間分の描画ポイントを求めています。

MediumWidgetView.swift
let pressureGraphPointWidth = (width / 24) * CGFloat(index) // 各時刻のグラフ描画のx軸
            
let heightPerHpa = height / 30 
let maxHpa = currentPressure + 15
let diffPressure = maxHpa - hourlyPressure
let pressureGraphPointHeight = diffPressure * heightPerHpa// 各時刻のグラフ描画のy軸

気圧グラフ描画のx軸は、描画範囲を24分割してindexと積すれば求められます。

y軸は少し複雑ですが、グラフ描画範囲を30で割った高さを1hpaあたりの高さとし、現在時刻の気圧に15を足した値を描画範囲の上限値としています。その上限値から各時間の気圧との差分を求めて、1hpaあたりの高さと積すれば各時間の気圧グラフ描画のy軸が求められます。今回のパターンだと気圧グラフ描画範囲の上限下限は現在時刻の気圧から15hpaということになります。

この計算が気圧グラフ描画の肝になると思いますが、結構苦労しました、、、
うまく伝われば幸いです。

ちなみに今回は「Open Weather API」のレスポンスで気圧の欠測値が確認できなかったので、欠測値を意識したコードは書いていません。(探せばありそう)

気圧グラフ実装後のプレビュー

スクリーンショット 2022-02-22 17.33.51.png
気圧の値はモックで設定しています。実際にビルドすると小数点が影響してもう少し滑らかな描画になります。

時刻テキスト、天気アイコン、気温の実装(赤ブロック)

MediumWidgetView.swift
import WidgetKit
import SwiftUI

struct PressureGraphPoint: Identifiable {
    var id = UUID()
    var points: [CGPoint] = []
}

struct MediumWidgetView: View {
    @Environment(\.colorScheme) var colorScheme
    var entry: MediumWidgetProvider.Entry
    
    var body: some View {
        let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date)
        let hourlyWeathers = entry.hourlyWeathers
        let timePeriodTexts = entry.timePeriodTexts
        let weatherIcons = entry.weatherIcons
        let temperatureTexts = entry.temperatureTexts
        let hourlyPressures = entry.hourlyPressures
        
        GeometryReader { geometry in
            let geometryWidth = geometry.size.width
            let geometryHeight = geometry.size.height
            let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth
            VStack {
                Spacer().frame(height: 16)
                VStack(spacing: 0) {
                    // 現在気温、現在時刻、地点名(青ブロック)
                    HStack {
                        Spacer()
                        HStack(spacing: 0) {
                            // 省略
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 6)
                        Spacer()
                    }
                    // グラフ(赤ブロック)
                    HStack {
                        Spacer()
                        HStack {
                            // 省略
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        Spacer()
                    }
                    // 時刻、天気アイコン、気温(緑ブロック)
                    HStack {
                        Spacer()
                        HStack(alignment: .top, spacing: 0) {
                            ForEach(0..<hourlyWeathers.count) { index in
                                VStack(alignment: .center, spacing: 0) {
                                    Text(timePeriodTexts[index])
                                        .foregroundColor(ColorManager.font)
                                        .font(.system(size: 14, weight: .medium))
                                        .fixedSize(horizontal: true, vertical: true)
                                        .frame(width: widthPerHour, alignment: .center)
                                    if isMultipleOfThree(hourlyWeather: hourlyWeathers[index]) {
                                        Image(weatherIcons[index])
                                            .resizable()
                                            .scaledToFill()
                                            .frame(width: widthPerHour, height: 27)
                                            .fixedSize(horizontal: true, vertical: true)
                                        Text("\(temperatureTexts[index])℃")
                                            .foregroundColor(ColorManager.font)
                                            .font(.system(size: 12, weight: .medium))
                                            .fixedSize(horizontal: true, vertical: true)
                                            .frame(width: widthPerHour, alignment: .center)
                                    }
                                }
                            }
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        Spacer()
                    }
                }
            }
        }
        .background(ColorManager.background)
    }
    
    private func isMultipleOfThree(hourlyWeather: Hourly) -> Bool {
        let hourDate = hourlyWeather.dt
        let date = Date(timeIntervalSince1970: hourDate)
        guard let dateInt = Int(DateFormatHelper.shared.formatToHH(date: date)) else {
            return false
        }
        
        if dateInt % 3 == 0 {
            return true
        } else {
            return false
        }
    }

    // 省略
}

struct MediumWidgetView_Previews: PreviewProvider {
    static var previews: some View {
        let entry = MediumWidgetEntryModel(
            currentDate: Date(timeIntervalSince1970: 1644048000),
            hourlyWeathers: MockHourly.data,
            currentLocation: "世田谷区"
        )
        
        Group {
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .light)
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .dark)
        }
    }
}

24時間分の時刻テキストと、天気アイコンと、気温を表示しています。新たに追加したisMultipleOfThreeで3の倍数の時間のみ天気アイコンと気温を表示するように判断しています。

時刻テキスト、天気アイコン、気温の実装後のプレビュー

スクリーンショット 2022-02-22 17.49.45.png
天気アイコンはモックで設定しています。これで完成です。

 コード全体

MediumWidgetView.swift
import WidgetKit
import SwiftUI

struct PressureGraphPoint: Identifiable {
    var id = UUID()
    var points: [CGPoint] = []
}

struct MediumWidgetView: View {
    @Environment(\.colorScheme) var colorScheme
    var entry: MediumWidgetProvider.Entry
    
    var body: some View {
        let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date)
        let hourlyWeathers = entry.hourlyWeathers
        let timePeriodTexts = entry.timePeriodTexts
        let weatherIcons = entry.weatherIcons
        let temperatureTexts = entry.temperatureTexts
        let hourlyPressures = entry.hourlyPressures
        
        GeometryReader { geometry in
            let geometryWidth = geometry.size.width
            let geometryHeight = geometry.size.height
            let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth
            VStack {
                Spacer().frame(height: 16)
                VStack(spacing: 0) {
                    // 現在気温、現在時刻、地点名
                    HStack {
                        Spacer()
                        HStack(spacing: 0) {
                            HStack(spacing: 0) {
                                Text(String(format: "%0.0f", hourlyWeathers[0].temp))
                                    .foregroundColor(ColorManager.font)
                                    .font(.system(size: 30, weight: .semibold))
                                    .offset(x: 5)
                                    .fixedSize(horizontal: true, vertical: true)
                                Text("℃" + " \(currentDate):00" +  "現在")
                                    .foregroundColor(ColorManager.font)
                                    .font(.system(size: 14, weight: .medium))
                                    .offset(x: 5)
                                    .fixedSize(horizontal: true, vertical: true)
                                    .frame(height: geometryHeight / 6, alignment: .bottom)
                            }
                            .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .leading)
                            if let location = entry.currentLocation {
                                Text(location)
                                    .foregroundColor(ColorManager.font)
                                    .font(.system(size: 14, weight: .medium))
                                    .offset(x: -5)
                                    .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .bottomTrailing)
                            }
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 6)
                        Spacer()
                    }
                    // グラフ
                    HStack {
                        Spacer()
                        HStack {
                            GeometryReader { graphGeometry in
                                let graphGeometryWidth = graphGeometry.size.width
                                let graphGeometryHeight = graphGeometry.size.height
                                let graphBackLineStartPoint = (widthPerHour * 0.5)
                                let graphBackLineEndPoint = graphGeometryWidth - graphBackLineStartPoint
                                let pressureGraphPoints = getPressureGraphPoints(hourlyPressures: hourlyPressures, width: graphGeometryWidth, height: graphGeometryHeight)
                                ZStack {
                                    // 背景ライン
                                    Path { path in
                                        path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y:3))
                                        path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: 3))
                                        path.move(to: CGPoint(x: graphBackLineStartPoint, y: graphGeometryHeight / 2))
                                        path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight / 2))
                                        path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y: graphGeometryHeight - 3))
                                        path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight - 3))
                                    }
                                    .stroke(ColorManager.graphBackground, lineWidth: 1)
                                    // 気圧グラフ
                                    ForEach(pressureGraphPoints) { pressureGraphPoint in
                                        Path { path in
                                            path.move(to: pressureGraphPoint.points[0])
                                            for index in 1..<pressureGraphPoint.points.count {
                                                path.addLine(to: pressureGraphPoint.points[index])
                                            }
                                        }
                                        .stroke(ColorManager.graph, lineWidth: 3)
                                        .offset(x: widthPerHour * 0.5) // timePeriodTextsのx軸と合わせて描画
                                        .clipped()
                                    }
                                    Text(String(format: "%0.0f", hourlyPressures[0] + 15))
                                        .font(.system(size: 6, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .topLeading)
                                    Text(String(format: "%0.0f", hourlyPressures[0]))
                                        .font(.system(size: 6, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .offset(y: 5)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .leading)
                                    Text(String(format: "%0.0f", hourlyPressures[0] - 15))
                                        .font(.system(size: 6, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomLeading)
                                    Text("hpa")
                                        .font(.system(size: 8, weight: .regular))
                                        .foregroundColor(ColorManager.font)
                                        .offset(x: -4, y: -4)
                                        .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomTrailing)
                                }
                            }
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        Spacer()
                    }
                    // 時刻、天気アイコン、気温
                    HStack {
                        Spacer()
                        HStack(alignment: .top, spacing: 0) {
                            ForEach(0..<hourlyWeathers.count) { index in
                                VStack(alignment: .center, spacing: 0) {
                                    Text(timePeriodTexts[index])
                                        .foregroundColor(ColorManager.font)
                                        .font(.system(size: 14, weight: .medium))
                                        .fixedSize(horizontal: true, vertical: true)
                                        .frame(width: widthPerHour, alignment: .center)
                                    if isMultipleOfThree(hourlyWeather: hourlyWeathers[index]) {
                                        Image(weatherIcons[index])
                                            .resizable()
                                            .scaledToFill()
                                            .frame(width: widthPerHour, height: 27)
                                            .fixedSize(horizontal: true, vertical: true)
                                        Text("\(temperatureTexts[index])℃")
                                            .foregroundColor(ColorManager.font)
                                            .font(.system(size: 12, weight: .medium))
                                            .fixedSize(horizontal: true, vertical: true)
                                            .frame(width: widthPerHour, alignment: .center)
                                    }
                                }
                            }
                        }
                        .frame(width: geometryWidth - 32, height: geometryHeight / 3)
                        Spacer()
                    }
                }
            }
        }
        .background(ColorManager.background)
    }
    
    private func isMultipleOfThree(hourlyWeather: Hourly) -> Bool {
        let hourDate = hourlyWeather.dt
        let date = Date(timeIntervalSince1970: hourDate)
        guard let dateInt = Int(DateFormatHelper.shared.formatToHH(date: date)) else {
            return false
        }
        
        if dateInt % 3 == 0 {
            return true
        } else {
            return false
        }
    }
    
    private func getPressureGraphPoints(hourlyPressures: [Double], width: CGFloat, height: CGFloat) -> [PressureGraphPoint] {
        let currentPressure = hourlyPressures[0]
        var pressureGraphPoints: [PressureGraphPoint] = []
        var tempPressurePoint = PressureGraphPoint()
        
        hourlyPressures.enumerated().forEach { index, hourlyPressure  in
            let pressureGraphPointWidth = (width / 24) * CGFloat(index)
            
            let heightPerHpa = height / 30
            let maxHpa = currentPressure + 15
            let diffPressure = maxHpa - hourlyPressure
            let pressureGraphPointHeight = diffPressure * heightPerHpa
            let points = CGPoint(x: pressureGraphPointWidth, y: pressureGraphPointHeight)
            
            tempPressurePoint.points.append(points)
        }
        
        pressureGraphPoints.append(tempPressurePoint)
        
        return pressureGraphPoints
    }
}

struct MediumWidgetView_Previews: PreviewProvider {
    static var previews: some View {
        let entry = MediumWidgetEntryModel(
            currentDate: Date(timeIntervalSince1970: 1644048000),
            hourlyWeathers: MockHourly.data,
            currentLocation: "世田谷区"
        )
        
        Group {
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .light)
            MediumWidgetView(entry: entry)
                .previewContext(WidgetPreviewContext(family: .systemMedium))
                .environment(\.colorScheme, .dark)
        }
    }
}

おわりに

今回でWidgetKitで天気予報アプリ作ってみたシリーズの完結です。
Viewの記事を書くのはなかなか難しかったですが、誰かの役に立てば幸いです。

一通り「Widget Extension」でアプリを開発してみた感想としては、「Provider」と「EntryModel」でタイムラインを作る箇所の理解に苦戦しました。ただ、タイムライン作成の流れと、正確なModelを作ることさえできれば残りはModel(Entry)からデータを取得してViewを書くだけなので、慣れてしまえばそこまで複雑には感じなくなりました。

ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。質問も受け付けています。

お知らせ

現在副業でiOSアプリ開発を募集しています。
Twitter DMでご依頼お待ちしております🙂
QR_615427.png
↓活動リンクはこちら↓
https://linktr.ee/sasaki.ken

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?