20220224のiOSに関する記事は8件です。

2022年のiOS/Androidの技術をざっくりキャッチアップ

ネイティブアプリにも宣言型UIとかなんかいろいろ入ってきたので、「いい加減そろそろ覚えないとな~~」ってなってる人は少なくないと思ってるんですけれど、一足先に一通り触ったのでどんな感じで学べばいいのかなという参考になればという感じの記事です。 宣言型UI WebにjQueryがもたらされたとき、だれでも簡単に直接DOMを触ってViewを弄れるようになった結果、アプリの大規模化・保守の長期化に伴って手に負えなくなったので、それを解決するためにReact辺りが宣言型UIの思想を生み出した…というざっくりした認識を持ってるんですけれど、合ってるかは知りません。 とまれ、ネイティブアプリにも数周遅れではありますが宣言型UIの流れがきています。 SwiftUI SwiftUIの現在の最新バージョンは3です。 どんどん便利になるのはいいことなんですけれど、サポートするiOSのバージョン指定によっては使えない機能とかがあります…。 これは公式のコードなのですけれど、 struct Content: View { @State var model = Themes.listModel var body: some View { List(model.items, action: model.selecteItem) { item in Image(item.image) VStack(alignment: .leading) { Text(item.title) Text(item.subtitle) .color(.gray) } } } } Opaque Result Type Property Wrapper Dynamic Member Lookup のような最近の言語仕様の追加を駆使して実現されてるため、UIを作るだけならスッキリなんですけれど、挙動についてもスッキリと理解するにはこうした言語仕様の変更も追いかける必要があります。 それぞれの言語仕様について、プロポーザルを眺めながら簡単なコードで動きを見てみたり、$model.itemsとか_model.items.wrappedValueとかの型情報をインスペクションで眺めたりすると掴めてくるのかなと思います。 Jetpack Compose ComposeはViewの組み立てが関数で構成されているというのが面白ポイントですね。 @Composable fun JetpackCompose() { Card { var expanded by remember { mutableStateOf(false) } Column(Modifier.clickable { expanded = !expanded } ) { Image(painterResource(R.drawable.jetpack_compose)) AnimatedVisibility(expanded) { Text( text = "Jetpack Compose", style = MaterialTheme.typography.h2, ) } } } } 最初はby rememberやmutableStateOf() が見慣れないかもしれませんが、翻訳済みの公式ドキュメントがかなり整備されているので、とっつきやすいと思います。 状態ホイスティングのようなCompose構築のための良いプラクティスも書いてありますし、ComposeはAndroid SDKとは別のライブラリとして提供されているので、バージョンによる分断もめいびーありません。 Composeがバージョンごとに特定のKotlinバージョンをロックインしている点と、Material Designも並行してアップデートしている辺りはちょっと困りポイントかもしれません。 リアクティブプログラミングっぽいの 初期の学習コストこそ高いものの、Reactive Extensionsを一つ覚えれば、他の言語でも知識を応用できる、みたいな話がありましたよね…。 Combine これはApple的にはリアクティブプログラミングではなく、非同期イベントを処理する仕組みみたいです。個人的な感想では、どう見てもRxでは…という感じです。 そういうわけで、Rxを理解しているとほぼほぼ「そーなのねー」で終わると思います。あとはSwift固有の問題として、オペレーターを挟むと型がどんどん入り組んでいくので、eraseAnyPublisher()による型消去を適当に挟む感じというのだけ覚えればよさそう。 CombineはSwiftUIの裏側ではいろいろ出てくるので、その辺りの理解度を高めるために習得するのはありなのかもですけれど、あんまり直接いじる印象はないと思います。たぶん。 Flow Flowも見た目はRxこそ似ていますが、Coroutineの一種という点に違いがあります。 RxやChannelは不慣れだとあっさりリソースをリークさせてしまうのですが、Flowは基本的にColdなストリームを扱いつつ、HotなStateFlowに変換するにはCoroutineScope内でやってね~みたいな感じがあり、ある程度安全性も担保している印象があります。 こちらも結局非同期イベントによる状態変化をComposeへ流す用途に使うのがメインだと思っていて、直接いじる印象は少ないです。 とはいえ、Hot/Coldについての最小限の知識がないと、サーバーに何度もアクセスしてしまう問題のあるコードが生まれるリスクはあるので、公式ドキュメントを眺めるくらいはしてもいいかも…。 非同期処理 C#にasync/awaitが出てから10年くらいでしょうか。ようやくどの言語でも同じような書き方ができるようになりました。 Swift Concurrency Swiftで非同期処理を書こうとすると、completionクロージャがネストしまくり、非常に追い辛いコードになりがちでした。 待ちに待ったSwift Concurrencyの登場によって、とても簡潔に書けるようになったと思います。 func fetchImage(request: URLRequest) async throws -> UIImage { let (data, response) = try await URLSession.shared.data(for: request) guard let image = UIImage(data: data) else { throw ImageDecodeError } return image } Swiftは2.0でキャッチ例外を取り入れることを選択したので、async/awaitだけではなく、async throws/try awaitがあるのが特色ですね。 ほかにもActorという非同期処理におけるRace Conditionの問題解決に大きく貢献する仕組みも入っています。 この辺りはWWDCのビデオを見るのが手っ取り早いと思います。非常に便利なんですけれど、SDKやエコシステムが対応していくようになるのはまだこれからかなという印象です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】AppleSilicon環境(MacOS)でiOSアプリを実行した時にMacで実行しているか判定する

iOSアプリをAppleSilicon環境(MacOS)で実行した時の判定 以下質問にて@nukka123さんに回答いただきました。 https://qiita.com/Howasuto/questions/3fc5ba81ea6240470ac4 以下のコードでAppleSilicon環境での実行時の判定を可能にします。 またUIDevice.current.userInterfaceIdiomでの判定基準はあくまでユーザーインターフェースのため、AppleSilicon環境で実行した際にはiPad用のインターフェースが適応されるため、判定結果としてはiPadとして判定されます。 if ProcessInfo.processInfo.isiOSAppOnMac { // iOSアプリをMacで実行時 print("iOSAppOnMac") } else if UIDevice.current.userInterfaceIdiom == .phone { // iPhone用インターフェースで実行時 print("iPhone") } else if UIDevice.current.userInterfaceIdiom == .pad { // iPad用インターフェースで実行時 print("iPad") } else if UIDevice.current.userInterfaceIdiom == .mac { // Mac用インターフェースで実行時 print("Mac") } 参考 https://developer.apple.com/documentation/foundation/processinfo/3608556-isiosapponmac https://developer.apple.com/documentation/uikit/mac_catalyst/choosing_a_user_interface_idiom_for_your_mac_app/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

投稿の経緯 この記事は、前回投稿したWidgetKitで天気予報アプリ作ってみたシリーズ~タイムライン作成編~の続編です。 今回はいよいよViewを実装していきます。 前回の記事を見てない人は先に↓こちら↓を確認してください。 環境 Swift 5.5 Xcode 13.2.1 サンプルプロジェクト GitHubにPushしています。気になる方はご覧ください。 https://github.com/ken-sasaki-222/WeatherWidget Viewで表示する内容 画像のように各ブロックごとに分割して開発していきます。周りの余白は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のwidth、heightはGeometryReaderで取得しており、こちらもインスタンス化して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完成イメージ 現在の気温、現在時、地点名の実装(青ブロック) 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") } これで、現在の気温、現在時、地点名の実装ができました。特に難しいことはしていないと思うので説明はしません(笑) 現在の気温、現在時、地点名を実装後のプレビュー 気圧グラフの実装(赤ブロック) 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」のレスポンスで気圧の欠測値が確認できなかったので、欠測値を意識したコードは書いていません。(探せばありそう) 気圧グラフ実装後のプレビュー 気圧の値はモックで設定しています。実際にビルドすると小数点が影響してもう少し滑らかな描画になります。 時刻テキスト、天気アイコン、気温の実装(赤ブロック) 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の倍数の時間のみ天気アイコンと気温を表示するように判断しています。 時刻テキスト、天気アイコン、気温の実装後のプレビュー 天気アイコンはモックで設定しています。これで完成です。  コード全体 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でご依頼お待ちしております? ↓活動リンクはこちら↓ https://linktr.ee/sasaki.ken
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIでテーブル形式のビュー(Listなど)でローディング中のスケルトンを適用する方法

はじめに ListやLazyVGridなどの画面初期化時にリスト数が決まっていないViewに対し、YoutubeやFacebookで使われているような、ロード中のスケルトンを表示する方法を書いてみました。 ちなみにスケルトンスクリーンとは、このようなやつです。 実装イメージは下記です。 結論 当たり前っちゃ当たり前なのですが、 リスト形式の場合、画面表示時には、表示数が0件なので、スケルトンを適用しても効果がありません。 なので、適当なDummyデータを作っておき、APIなどで本来表示すべき値が取得できたら、Dummyデータと置き換えて表示してあげればうまくいきます。 実装 結論で書いたことが、全てですので、簡単な実装だけご紹介します。 実際にAPIなどへリクエストすることを想定し、Combineを利用していますが、ここではそれについては触れません。 Combineについては、以前この記事で書かせていただきましたのでもし良かったら、ご覧いただければと思います。 まず、ロード中にスケルトンを表示するためには、redacted(reason:)が標準で用意されているので、それを使います。 ContentViewModel.swift struct UserInfo: Identifiable { let id: Int let name: String let age: Int let email: String } class ContentViewModel: ObservableObject { var cancellables = Set<AnyCancellable>() @Published var userInfoList = dummyData @Published var isFirstLoading = true let fetchUserSubject = PassthroughSubject<Void, Never>() static var dummyData: [UserInfo] { return (1...10).map { UserInfo(id: $0, name: "DummyName", age: 99, email: "dummy@test.com") } } init() { fetchUserSubject .flatMap({ _ -> AnyPublisher<[UserInfo], Never> in let userInfoList = (1...3).map { UserInfo(id: $0, name: "RealName", age: 10, email: "real@real.com") } return Just(userInfoList).eraseToAnyPublisher() }) .delay(for: 2, scheduler: DispatchQueue.main) .sink(receiveValue: { userInfoList in self.userInfoList = userInfoList self.isFirstLoading = false }) .store(in: &cancellables) } } static var dummyData: [UserInfo]を定義して、@Published var userInfoList = dummyDataのようにして、初期化時はダミーデータが入るようにします。 ViewModelでは、let fetchUserSubject = PassthroughSubject<Void, Never>()を購読していますので、View側から値がsend()されたら、リアルデータをクラス変数にセットする処理が走ります。 本来は、APIによってデータを取得しますが、今回は、2秒後に固定値のリアルデータが帰ってくるように、処理を書いています。 ContentView.swift struct ContentView: View { @ObservedObject var viewModel = ContentViewModel() var body: some View { VStack { List(viewModel.userInfoList) { user in HStack { Text(user.name) Spacer() Text("\(user.age)歳") Spacer() Text(user.email) } } .redacted(reason: viewModel.isFirstLoading ? .placeholder : []) } .onAppear { viewModel.fetchUserSubject.send(()) } } } .redacted(reason: viewModel.isFirstLoading ? .placeholder : []) をListに対して適用することで、Listの{}で囲まれたViewがすべてスケルトン適用されます。 viewModel.isFirstLoadingは、リアルデータが取得できた時にfalseになるようにしているので、データが取れたら、自動的にスケルトンが解除されます。 以上となります。 List形式のViewで、ロード中のスケルトンを実装したい方の参考になれば幸いです。 参考にさせていただいた記事
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

カテゴリとクラス拡張についてメモ

 UIImage+Rect.mとか、NSString+size.mとか、不思議だなと思っていた。 やっていることは、もともとのiOSフレームワークを活用して、やりたい機能だけ追加しているという感じだった。 なので、もともと存在するiOSフレームワークにだけ使える拡張機能かと思っていたが、違うらしい。 新規で作成したVCなどにも活用できる。 いや、そりゃそうでしょって感じなのですが。 クラス拡張は、UIImageやNSString、UIButton、UILabelなどよく使うUIKitやFoundationの機能拡張が多い。 カテゴリは。冗長化してきたソースを機能ごとに分割するのに使うケースが主流なのかな。 そうじゃないよ、というときは、指摘していただければ、確認しますので、 気楽にコメントください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ネイティブアプリの審査通過に向けて気をつけたポイント(2022年2月版)

0. 概要 この度、新規のネイティブアプリをiOS/Androidの両プラットフォームにてスケジュール通りにリリース可能な(Apple/Googleによる審査が通過した)状態にすることができました! ※審査時間がとても早かった(通常、初回は2週間ほどを見るのが良いと言われていますが、提出から通過までなんと1営業日…!)というラッキーはありましたが、間違いなく開発チームの努力の成果だと思っています! チームの中でも私個人は今回、技術的な側面ではなく、アプリが審査基準を満たせているか?審査提出に必要なタスクは何か?といった側面において、リスク回避に努めました。スケジュール通りに何かを成し遂げる際は「不確定要素」をどれだけ減らしていくかといったことが重要だと思いますが、アプリのサービスリリースにおいて最大の不確定要素は「審査でのリジェクト」になるかと思います。今回得た知見で、どなたかの「不確定要素」を減らす手助けが出来れば…!という気持ちです。 1. 公式のガイドラインを読む 兎にも角にもまずは公式のガイドラインを読みました。入り口が分かりづらかったり、各所に情報が散らばっていたり、色々と思うところはありますがまずは一読しておいた方が良いと思います。 iOS https://developer.apple.com/jp/app-store/review/ https://developer.apple.com/jp/ios/submit/ 特に注視したのは、App Store Reviewガイドラインです。審査はこちらの基準に則って行われるはずなので、少々文量はありますが、端から端までしっかり読むのが良いと思います。中でも気になった点を以下に紹介しておきます。 アカウントの作成に対応したAppの場合は、App内でアカウントの削除もできるようにする必要があります。 こちらに公式のプレスが載っていますが、アプリ内にてアカウント作成機能を提供している場合は、2022年1月31日よりApp内のアカウント削除機能の提供が必須要件になっています。引用した文章は太字にもなっておらずしれっと記載されています。ググるといくつか記事も見当たりますので、iOSエンジニアの方からすると知っていて当然のニュースという感じかもしれませんが、アプリ開発未経験者からするとあまりにも不親切…と思いました。 ユーザーコンテンツやアプリ内課金がある場合などアプリの要件に合わせて、各項目をしっかりチェックする方が良いと思います。上記のように機能開発が必要なのにも関わらず、審査でリジェクトされるまで気づけないと精神的にかなりきついところがあると思います。当然リリース日は伸びてしまいますし、急ぎで作った機能は品質の面などで不安になってしまうでしょう。今回は他の開発メンバーがプレスに気づき比較的早い段階で対応できましたが、不安要素は早め早めに潰しておくと良いなと改めて感じました。 Android https://play.google.com/about/developer-content-policy/?hl=ja https://support.google.com/googleplay/android-developer/answer/9859152?hl=ja こんなことを言うと怒られてしまうかもしれませんが、個人的にAndroidはiOSほど情報が上手くまとまっていないように思いました。昔はそもそも審査がなかったり、審査基準がAppleと比較すると緩いという話もあったりしますし、整備がまだまだ行き届いていないのかもしれません。 私は特に1つ目のデベロッパーポリシーの方を注視しました。こちらもかなりの文量ですが、iOS同様しっかり読むことをおすすめします。中でも気になった点をいくつかご紹介します。 著作権で保護されているコンテンツを使用する場合は、その権利の証拠を示すよう求められることがあります。 例えば、他社からアプリ開発の委託を受けている場合や他社とアライアンスを組んでいる場合など、デベロッパーアカウントの情報と直接に関係のないロゴを使用していると、権限を保有しているのか審査で尋ねられることがあるそうです。私達のアプリは前述の例でいうところの後者に該当するサービスなのですが、幸いなことに今回は指摘がありませんでした。こういった権利周りのセンシティブな情報の外部公開にはそれなりに時間がかかると思います。Googleの審査チームへの事前フォームがあり、ファイルを添付してメッセージを送信できるようになっているので、用意可能な場合は必要書類をフォームで提出しておくと良さそうです。 アプリの Google Play 掲載情報に記載されている主体(デベロッパーや会社等)がプライバシー ポリシーに明記されている、もしくはアプリ名がプライバシーポリシーに明記されている必要があります。 1つの会社でいくつもサービスを持っている場合、全社で共通のプライバシーポリシーを使用しているという場合があると思います。私達が正に同じ状況にあり、該当のプライバシーポリシーにアプリの名称の記載がなかったので困りました。結論、私達はサービス固有のプライバシーポリシーを用意して審査に臨むことにしました。そのため、アプリ名称が記載されていないことでリジェクトされるかどうかは実際にはわからないですが、リスクを可能な限り回避するためには対応しておく方が良いと思います。アプリで使用している第三者サービスのプライバシーポリシーを調査する必要があったり、法務に内容を相談する必要があったり、一朝一夕で用意できるものではないと思うので可能な限り早めに対応しておくと良さそうです。 すべてのデベロッパーは、すべてのアプリについて、ユーザーデータの収集、使用、共有に関する詳細な説明を、データセーフティセクションに明瞭かつ正確に記載する必要があります。 こちらに詳細記載されていますが、データセーフティセクションというものが新設され2022年4月までに内容を申告する必要があるそうです。PlayConsoleにて自身のサービスのプライバシーポリシーをもとにいくつかの質問に回答する形なので、物量はありましたが内容自体そこまで大変なものではありませんでした。 こちらのプライバシーに関する情報提供についてですが、iOSにも似たようなセクションがあり2020年12月より提出が必須になっています。詳細はこちらに記載がありますのでご参照ください。 2. 審査に必要な準備を知る 審査に提出するには何をする必要があるのかチェックリストのようなものがあれば一番良いのですが、残念ながらそういったリストの用意はありません。※Androidの場合はPlayConsoleのダッシュボードの画面にチェックリストが表示されています。 前述のガイドラインをよく読んで審査基準を満たしているか確認するとともに、スクリーンショットや掲載文などのアプリのメタ情報を用意します。公式のヘルプページを見つつ用意する形になると思いますが、実際にデベロッパーコンソールやアプリストアを見つつ、何が必要かどのように使われるのかイメージを掴みながら用意するのが手っ取り早くかつ最も分かりやすいと思います。 iOS https://developer.apple.com/jp/app-store/product-page/ Android https://support.google.com/googleplay/android-developer/answer/9859455?hl=ja&ref_topic=7072031 https://support.google.com/googleplay/android-developer/topic/3450987?hl=ja&ref_topic=7072031 ここで忘れがちだったり後回しにしがちだったりするので注意しておきたい点なのですが、審査に提出する(審査をしていただく)ということは審査を行うことのできる環境が必要です。ECなどアプリ上で金銭のやりとりが発生する場合や、WEBが先行で既に本番稼働していてサーバーサイドはアプリと共通といった場合などは、審査の際に実際に操作されてしまうと困るという事情があると思います。私達は後者の例にあてはまったのですが、レビュー時に操作しないでほしいという旨の注意書きと、YouTubeに限定公開した機能説明の動画のリンクを提出するという手法で乗り切りました。実際、審査中に該当の操作を行ったログはなかったですし、審査も無事に通過したので有効な手段ということが言えると思います。MacがあればiMovieなどを使って手軽に動画作成もできるのでおすすめです。 また、アプリ内にアカウントの作成機能がある場合は、審査用にログイン可能なデモアカウントを提出する必要がありますのでご注意ください。私たちの場合、SMS認証が必要なサービスなのでどのように対応するか困ったのですが、FirebaseAuthを使っていたので架空の電話番号と認証コードを用意して対応しました。便利! Ref: https://firebase.google.com/docs/auth/web/phone-auth?hl=ja#:~:text=Firebase Authentication では、実際の,番号として使用します。 3. 最後に いかがでしたでしょうか?アプリエンジニアの方からすれば知っていて当然のことかもしれないですが、初見の人間からすると知らない、見えない分非常に神経を使う作業でした。 アプリのスクリーンショットは熟練のデザイナーさんが対応してくださったり、掲載文は企画の方が他サービスの調査を踏まえつつ用意してくださったり、品質はQAエンジニアの方が綿密なテストを実施して担保してくださったりと色々な方の協力があったので、私は漠然とした不安を抱えながらも、安心して全体のチェックに時間を割くことができました。アプリの要件としても、金銭のやりとりやユーザーに寄るコンテンツの作成だったり基準の厳しい機能がなかったのも初心者としては対応しやすかったという面もあったと思います。 稚拙な文で大変恐縮ですが、ここまで読んでくださりありがとうございました。私と同じように、これから世の中にアプリを出していこうという方の少しでも手助けとなれば幸いです。また、こちらの文章に記載している記事や公式の審査基準等は今後も更新されていくはずなので、常にApple/Googleの最新の公式ページを確認したり、情報をウォッチしていく必要があると思います。他にも気をつけるべき点などが思い浮かんだ方はぜひ実体験なども踏まえてコメントしていただけると大変ありがたいです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ネイティブアプリの初回審査通過に向けて気をつけたポイント(2022年2月版)

0. 概要 この度、新規のネイティブアプリをiOS/Androidの両プラットフォームにてスケジュール通りにリリース可能な(Apple/Googleによる審査が通過した)状態にすることができました! ※審査時間がとても早かった(通常、初回は2週間ほどを見るのが良いと言われていますが、提出から通過までなんと1営業日…!)というラッキーはありましたが、間違いなく開発チームの努力の成果だと思っています! チームの中でも私個人は今回、技術的な側面ではなく、アプリが審査基準を満たせているか?審査提出に必要なタスクは何か?といった側面において、リスク回避に努めました。スケジュール通りに何かを成し遂げる際は「不確定要素」をどれだけ減らしていくかといったことが重要だと思いますが、アプリのサービスリリースにおいて最大の不確定要素は「審査でのリジェクト」になるかと思います。今回得た知見で、どなたかの「不確定要素」を減らす手助けが出来れば…!という気持ちです。 1. 公式のガイドラインを読む 兎にも角にもまずは公式のガイドラインを読みました。入り口が分かりづらかったり、各所に情報が散らばっていたり、色々と思うところはありますがまずは一読しておいた方が良いと思います。 iOS https://developer.apple.com/jp/app-store/review/ https://developer.apple.com/jp/ios/submit/ 特に注視したのは、App Store Reviewガイドラインです。審査はこちらの基準に則って行われるはずなので、少々文量はありますが、端から端までしっかり読むのが良いと思います。中でも気になった点を以下に紹介しておきます。 アカウントの作成に対応したAppの場合は、App内でアカウントの削除もできるようにする必要があります。 こちらに公式のプレスが載っていますが、アプリ内にてアカウント作成機能を提供している場合は、2022年1月31日よりApp内のアカウント削除機能の提供が必須要件になっています。引用した文章は太字にもなっておらずしれっと記載されています。ググるといくつか記事も見当たりますので、iOSエンジニアの方からすると知っていて当然のニュースという感じかもしれませんが、アプリ開発未経験者からするとあまりにも不親切…と思いました。 ユーザーコンテンツやアプリ内課金がある場合などアプリの要件に合わせて、各項目をしっかりチェックする方が良いと思います。上記のように機能開発が必要なのにも関わらず、審査でリジェクトされるまで気づけないと精神的にかなりきついところがあると思います。当然リリース日は伸びてしまいますし、急ぎで作った機能は品質の面などで不安になってしまうでしょう。今回は他の開発メンバーがプレスに気づき比較的早い段階で対応できましたが、不安要素は早め早めに潰しておくと良いなと改めて感じました。 Android https://play.google.com/about/developer-content-policy/?hl=ja https://support.google.com/googleplay/android-developer/answer/9859152?hl=ja こんなことを言うと怒られてしまうかもしれませんが、個人的にAndroidはiOSほど情報が上手くまとまっていないように思いました。昔はそもそも審査がなかったり、審査基準がAppleと比較すると緩いという話もあったりしますし、整備がまだまだ行き届いていないのかもしれません。 私は特に1つ目のデベロッパーポリシーの方を注視しました。こちらもかなりの文量ですが、iOS同様しっかり読むことをおすすめします。中でも気になった点をいくつかご紹介します。 著作権で保護されているコンテンツを使用する場合は、その権利の証拠を示すよう求められることがあります。 例えば、他社からアプリ開発の委託を受けている場合や他社とアライアンスを組んでいる場合など、デベロッパーアカウントの情報と直接に関係のないロゴを使用していると、権限を保有しているのか審査で尋ねられることがあるそうです。私達のアプリは前述の例でいうところの後者に該当するサービスなのですが、幸いなことに今回は指摘がありませんでした。こういった権利周りのセンシティブな情報の外部公開にはそれなりに時間がかかると思います。Googleの審査チームへの事前フォームがあり、ファイルを添付してメッセージを送信できるようになっているので、用意可能な場合は必要書類をフォームで提出しておくと良さそうです。 アプリの Google Play 掲載情報に記載されている主体(デベロッパーや会社等)がプライバシー ポリシーに明記されている、もしくはアプリ名がプライバシーポリシーに明記されている必要があります。 1つの会社でいくつもサービスを持っている場合、全社で共通のプライバシーポリシーを使用しているという場合があると思います。私達が正に同じ状況にあり、該当のプライバシーポリシーにアプリの名称の記載がなかったので困りました。結論、私達はサービス固有のプライバシーポリシーを用意して審査に臨むことにしました。そのため、アプリ名称が記載されていないことでリジェクトされるかどうかは実際にはわからないですが、リスクを可能な限り回避するためには対応しておく方が良いと思います。アプリで使用している第三者サービスのプライバシーポリシーを調査する必要があったり、法務に内容を相談する必要があったり、一朝一夕で用意できるものではないと思うので可能な限り早めに対応しておくと良さそうです。 すべてのデベロッパーは、すべてのアプリについて、ユーザーデータの収集、使用、共有に関する詳細な説明を、データセーフティセクションに明瞭かつ正確に記載する必要があります。 こちらに詳細記載されていますが、データセーフティセクションというものが新設され2022年4月までに内容を申告する必要があるそうです。PlayConsoleにて自身のサービスのプライバシーポリシーをもとにいくつかの質問に回答する形なので、物量はありましたが内容自体そこまで大変なものではありませんでした。 こちらのプライバシーに関する情報提供についてですが、iOSにも似たようなセクションがあり2020年12月より提出が必須になっています。詳細はこちらに記載がありますのでご参照ください。 2. 審査に必要な準備を知る 審査に提出するには何をする必要があるのかチェックリストのようなものがあれば一番良いのですが、残念ながらそういったリストの用意はありません。※Androidの場合はPlayConsoleのダッシュボードの画面にチェックリストが表示されています。 前述のガイドラインをよく読んで審査基準を満たしているか確認するとともに、スクリーンショットや掲載文などのアプリのメタ情報を用意します。公式のヘルプページを見つつ用意する形になると思いますが、実際にデベロッパーコンソールやアプリストアを見つつ、何が必要かどのように使われるのかイメージを掴みながら用意するのが手っ取り早くかつ最も分かりやすいと思います。 iOS https://developer.apple.com/jp/app-store/product-page/ Android https://support.google.com/googleplay/android-developer/answer/9859455?hl=ja&ref_topic=7072031 https://support.google.com/googleplay/android-developer/topic/3450987?hl=ja&ref_topic=7072031 ここで忘れがちだったり後回しにしがちだったりするので注意しておきたい点なのですが、審査に提出する(審査をしていただく)ということは審査を行うことのできる環境が必要です。ECなどアプリ上で金銭のやりとりが発生する場合や、WEBが先行で既に本番稼働していてサーバーサイドはアプリと共通といった場合などは、審査の際に実際に操作されてしまうと困るという事情があると思います。私達は後者の例にあてはまったのですが、レビュー時に操作しないでほしいという旨の注意書きと、YouTubeに限定公開した機能説明の動画のリンクを提出するという手法で乗り切りました。実際、審査中に該当の操作を行ったログはなかったですし、審査も無事に通過したので有効な手段ということが言えると思います。MacがあればiMovieなどを使って手軽に動画作成もできるのでおすすめです。 また、アプリ内にアカウントの作成機能がある場合は、審査用にログイン可能なデモアカウントを提出する必要がありますのでご注意ください。私たちの場合、SMS認証が必要なサービスなのでどのように対応するか困ったのですが、FirebaseAuthを使っていたので架空の電話番号と認証コードを用意して対応しました。便利! Ref: https://firebase.google.com/docs/auth/web/phone-auth?hl=ja#:~:text=Firebase Authentication では、実際の,番号として使用します。 3. 最後に いかがでしたでしょうか?アプリエンジニアの方からすれば知っていて当然のことかもしれないですが、初見の人間からすると知らない、見えない分非常に神経を使う作業でした。 アプリのスクリーンショットは熟練のデザイナーさんが対応してくださったり、掲載文は企画の方が他サービスの調査を踏まえつつ用意してくださったり、品質はQAエンジニアの方が綿密なテストを実施して担保してくださったりと色々な方の協力があったので、私は漠然とした不安を抱えながらも、安心して全体のチェックに時間を割くことができました。アプリの要件としても、金銭のやりとりやユーザーに寄るコンテンツの作成だったり基準の厳しい機能がなかったのも初心者としては対応しやすかったという面もあったと思います。 稚拙な文で大変恐縮ですが、ここまで読んでくださりありがとうございました。私と同じように、これから世の中にアプリを出していこうという方の少しでも手助けとなれば幸いです。また、こちらの文章に記載している記事や公式の審査基準等は今後も更新されていくはずなので、常にApple/Googleの最新の公式ページを確認したり、情報をウォッチしていく必要があると思います。他にも気をつけるべき点などが思い浮かんだ方はぜひ実体験なども踏まえてコメントしていただけると大変ありがたいです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

四角形を検出する

画像内の四角形を検出する方法です 書類や標識の検出に 四角形の人工物を検出できれば、場面に応じて様々な用途に活かせそうです。 例えば、ドキュメント内の四角形の検出など。 Visionの四角形検出が使える 。 方法 画像にVNDetectRectangleRequestをするだけです。 let request = VNDetectRectanglesRequest() let handler = VNImageRequestHandler(ciImage: ciImage!, options: [:]) try! handler.perform([request]) guard let results = request.results else { return } for result in results { print("topLeft: \(result.topLeft)") print("topRight: \(result.topRight)") print("bottomLeft: \(result.bottomLeft)") print("bottomRight: \(result.bottomRight)") print("boundingBox: \(result.boundingBox)") } topLeft: (0.37335851788520813, 0.5885436534881592) topRight: (0.5980302095413208, 0.6653665900230408) bottomLeft: (0.45727139711380005, 0.16498719155788422) bottomRight: (0.7020156383514404, 0.26323217153549194) boundingBox: (0.37335851788520813, 0.16498719155788422, 0.3286571204662323, 0.5003793984651566) 画像内の正規化座標を取得できます。 Y座標は、画像の下が基準になっています。 ? フリーランスエンジニアです。 お仕事のご相談こちらまで rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 Twitter Medium
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む