- 投稿日:2020-07-26T22:11:44+09:00
[swift5]端末毎のビュー崩れを防ぐ方法(コードで指定)
はじめに
今回はiPhoneの端末サイズによってビューが崩れる現象を修正する実装方法についてアウトプットします。具体的な方法はいくつかありますが、今回はコードで要素の位置を指定する方法です。
環境
swift5
Xcode 11.6要素の位置を指定
基本文法: 'CGRect(x:値, y:値, width:値, height:値)'
基本的にはHTMLのボックモデルと考え方が似ており、左上を基準に要素を動かします。
x軸は右に動かせば+、y軸は下に動かせば+となる。widthとheightは指定した要素の横縦のサイズを指定する。
液晶全体の幅に合わせたい場合は、'view.frame.size.width(height)'と指定すれば画面いっぱいに要素を配置してくれる。
- 投稿日:2020-07-26T21:57:50+09:00
Swiftでインスタグラムへのシェア導線をつけてみる
はじめに
今回はアプリからインスタグラムのフィードへのシェア導線をつける実装です。詳しくはこちらに書いてあります??
インスタグラムに遷移できるようにする
まずは、アプリからインスタグラムの Custom URL Scheme が利用できるように info.plist の
LSApplicationQueriesSchemes
にinstagram://
を追加していきましょう。画像を保存して LocalIdentifier を取得する
func saveImage() { UIImageWriteToSavedPhotosAlbum(image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil) } @objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { let fetchOptions: PHFetchOptions = PHFetchOptions() fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] let fetchResult = PHAsset.fetchAssets(with: PHAssetMediaType.image, options: fetchOptions) if (fetchResult.firstObject != nil) { guard let lastAsset = fetchResult.lastObject else { return } let localIdentifier = lastAsset.localIdentifier } }LocalIdentifier を使って Custom URL Scheme でインスタグラムに遷移する
@objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { let fetchOptions: PHFetchOptions = PHFetchOptions() fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] let fetchResult = PHAsset.fetchAssets(with: PHAssetMediaType.image, options: fetchOptions) if (fetchResult.firstObject != nil) { guard let lastAsset = fetchResult.lastObject, let urlScheme = URL(string: "instagram://library?LocalIdentifier=\(lastAsset.localIdentifier)") else { return } if UIApplication.shared.canOpenURL(urlScheme) { UIApplication.shared.open(urlScheme) } } }
- 投稿日:2020-07-26T20:25:35+09:00
【Xcode Behaviors】Xcodeのイベントに合わせて動作を設定する
開発効率を高める便利機能
Xcodeの設定にBehaviorsという項目があるのをご存知でしょうか?
この項目から、Build/Testing/Runningなどの各イベント発生時の挙動を設定することができます。例えば次のGIFのように、RunningのPausesイベント(デバッグ中のエラー発生やブレイクポイント到達)発生時に、別のタブを開いて該当部分を表示するといったことが可能です。
デフォルトの設定では、エラーが発生するたびに編集中のファイルからエラー発生箇所に移動してしまいますが、別タブで開く設定をしておけば、タブを移動するだけで編集中の状態に戻れるので、ストレスが軽減します。デバッグ中にエラーが発生したときやブレイクポイントに達したときに別のタブを開く
Behaviors > Running > Pauses から以下の項目を設定します。
- Show tab named "Debug" in "active window"
- "Show" navigator "Debug navigator"
- "Show" debugger with "Variables & Console View"
- "Hide" inspectors
ナビゲーターに"Debug navigator"に選択することで、スタックトレースが表示されます。
デバッガーに"Variables & Console View"を選択することで、変数の内容を表示するビューとコンソールが表示されます。
ちなみにタブ名とインスペクターを隠す設定は任意です。ビルドに失敗したときに問題箇所を表示するナビゲーターを開く
Behaviors > Build > Fails から以下の項目を設定します。
- "Show" navigator "Issue navigator"
この項目はデフォルトで設定されているべきでは?と思うのですが、なぜかデフォルトではないようです。
テストに失敗したときに問題箇所を表示するナビゲーターを開く
ビルドのときと同様です。
Behaviors > Testing > Failsから以下の項目を設定します。
- "Show" navigator "Test navigator"
参考
公式ドキュメント(あまり情報がない)
- Customizing Your Workflow
- Configure actions for eventsタブをさらに使いこなしたい方はこちらが詳しいです
XcodeのBehaviorsを設定してデバッグ時にウインドウを自動で切り替える他の設定項目についてはこちら
Xcodeのオススメ初期セットアップ
- 投稿日:2020-07-26T20:25:35+09:00
【Xcode Behaviors】Xcodeのイベントに合わせて動作を設定する - 開発効率をUPする便利機能
開発効率をUPする便利機能
Xcodeの設定にBehaviorsという項目があるのをご存知でしょうか?
この項目から、Build/Testing/Runningなどの各イベント発生時の挙動を設定することができます。例えば次の画像のように、RunningのPausesイベント(デバッグ中のエラー発生やブレイクポイント到達)発生時に、別のタブを開いて該当部分を表示するといったことが可能です。
デフォルトの設定では、エラーが発生するたびに編集中のファイルからエラー発生箇所に移動してしまいますが、別タブで開く設定をしておけば、タブを移動するだけで編集中の状態に戻れるので、ストレスが軽減します。デバッグ中にエラーが発生したときやブレイクポイントに達したときに別のタブを開く
Behaviors > Running > Pauses から以下の項目を設定します。
- Show tab named "Debug" in "active window"
- "Show" navigator "Debug navigator"
- "Show" debugger with "Variables & Console View"
- "Hide" inspectors
ナビゲーターに"Debug navigator"に選択することで、スタックトレースが表示されます。
デバッガーに"Variables & Console View"を選択することで、変数の内容を表示するビューとコンソールが表示されます。
ちなみにタブ名とインスペクターを隠す設定は任意です。ビルドに失敗したときに問題箇所を表示するナビゲーターを開く
Behaviors > Build > Fails から以下の項目を設定します。
- "Show" navigator "Issue navigator"
この項目はデフォルトで設定されているべきでは?と思うのですが、なぜかデフォルトではないようです。
テストに失敗したときに問題箇所を表示するナビゲーターを開く
ビルドのときと同様です。
Behaviors > Testing > Failsから以下の項目を設定します。
- "Show" navigator "Test navigator"
参考
公式ドキュメント(あまり情報がない)
- Customizing Your Workflow
- Configure actions for eventsタブをさらに使いこなしたい方はこちらが詳しいです
XcodeのBehaviorsを設定してデバッグ時にウインドウを自動で切り替える他の設定項目についてはこちら
Xcodeのオススメ初期セットアップ
- 投稿日:2020-07-26T20:00:37+09:00
[iOS]APIKit + Combine + SwiftUIでGitHubリポジトリ検索
iOS13に出てきたSwiftUI・Combineと、ライブラリのAPIKitを使い、通信部分の実装手法を紹介したいと思います。
今回は、GitHub APIを使用します。実装のポイント
- できるだけシンプル:冗長なコードはなるべく書かない
- 保守性がある:読みやすいコード
- 再利用性がある:別の異なるAPIでも少しの変更で利用が可能
開発環境・使用するライブラリなど
開発環境
- OS:10.15.6
- Xcode:10.6
- iOS:iOS13以降(iOS14では確認していません)
使用するライブラリ
APIKit
今回は、APIKitを拡張し、CombineのPublisherでAPIを叩いた結果を取得します。使用するフレームワーク
- SwiftUI
- Combine
また、Swift標準のDecodableを使い、JSONをパースします。
使用する考え方
- リポジトリパターン:APIやDBにアクセスするための定義を抽象化
実装
全体像
- ContentView:UI表示
- GitHubSearchModel:ロジックを管理するクラス
- GitHubRepository:APIへのアクセス方法を定義
API基本部分
API部分のクラス相関図は以下のようになります。
GitHubRepository.swiftについては、中身は以下のようになっています。
GitHubRepository.swiftimport Foundation import APIKit class GitHubRepository { //GitHubレスポンス用のデコーダー static let decoder: GitHubDecoder = .init() //リポジトリを検索 struct SearchRepositories: GitHubRequestProtocol { //https://developer.github.com/v3/search //検索クエリ let query: String let method: HTTPMethod = .get let path: String = "/search/repositories" var decoder: JSONDecoder { return GitHubRepository.decoder } var params: [String: Any] { return [ "q": query ] } typealias Response = SearchResponse } private init() { } }
GitHubRepository
はリポジトリなので、APIアクセスに必要な情報だけを定義(抽象化)しています。
SearchResponseはDecodableに準拠しています。
GitHubRequestProtocol.swiftとGitHubDecoder.swiftとAPIDataParser.swiftはソースをご確認ください。ここまでで、GitHub APIを叩くための準備ができました。
Modelの実装
GitHub APIを使い、情報を取ってくるロジック(モデル)を実装します。
今回はViewとしてSwiftUIを使用するので、ロジックはObservableObjectを継承したクラスにします。GitHubSearchModel.swift/// 検索モデル class GitHubSearchModel: ObservableObject { @Published var items: [SearchItem] = [] //検索結果 ・・・・ /// 検索を行う func search() { debugPrint("search") // 1)APIリクエストを作成 let request = GitHubRepository.SearchRepositories(query: searchText) // 2)CombineのPublisherを作成し、通信処理を行う self.requestCancellable = request.publisher //Publisherに変換(この時点で通信処理を行なっている) .receive(on: DispatchQueue.main) //メインスレッドで受け取る .sink(receiveCompletion: { result in ・・・・ }, receiveValue: { [weak self] response in // 3)結果をitemsに保存 self?.items = response.items }) // 4)itemsの更新はCombineを通して通知される }1) APIリクエストを前述のリポジトリから作成します。
2) 通信処理を行います。
3) 結果をitems
に格納します
4)items
の変更はCombineによりUIに通知されます。UI
ContentView
こちらをご確認ください。実際に使ってみた
ソースコード
今回のソースコードはこちらに置いてあります。
https://github.com/usk-lab/APIKitCombine再利用性について
今回はGitHub APIを使用しました。
GitHubRepository
,GitHubRequestProtocol
,GitHubDecoder
を少し変えることで他のAPIサービスでも利用できます。参考
- https://github.com/ishkawa/APIKit/
- https://developer.github.com/v3/search/
- https://qiita.com/woxtu/items/ec689cf7709c3ce7d954
- https://qiita.com/renchild8/items/6cdaaad52c900f6efd38
- https://qiita.com/sgr-ksmt/items/e822a379d41462e05e0d
- https://dev.classmethod.jp/articles/rxswift-apikit-decodable-incremental-search/
- https://dev.classmethod.jp/articles/ios13-swiftui-combine-incremental-search/
- 投稿日:2020-07-26T17:37:21+09:00
ラブホテル検索アプリを作った話
こんにちは!久しぶりにQiita記事を書きます!
今回は現在地から近い・安いラブホテル検索アプリを開発したので、紹介&実装の一部を今後振り返れるよう書き記します!!
https://apps.apple.com/jp/app/ラブホテル簡単検索-ホテルイキタイ/id1524262826
是非、覗いて見てください…笑作った理由
- 検索時の情報過多を解消するアプリを作りたいと思った。 (ex.グルメアプリなど多くの情報を入力してそれに適したものを表示してくれるが、その初めの入力作業が億劫に感じた。)
- Pinterestのような、まずレコメンドを表示する。探索と検索両方を合わせ持つアプリを他の分野で作りたいと思った。
- モバイルに適応している(UI、UX)ラブホテル検索サービスがあまりないと感じたから。
上記の点よりラブホテルの検索アプリを作ろうと決めました!!
GAS(Google Apps Script)とFireStore連携
アプリを作るにあたってまず、都内のラブホテルの情報をGoogleスプレッドシートに書き込み、書き込んだ情報をFireStoreにコレクションとして追加しました。
ここでは2つのスプレッドシートを作りました。
一つ目はホテルの簡易な情報、二つ目は詳細な情報です。
これらをFireStoreに追加します。下記の記事を参考にさせていただきました。詳しい手順はこちらをみていただければと思います。
https://medium.com/@m_coder/バックエンドの知識がない自分がfirestore-googleappsscriptで簡易dbを構築した話-def690bb3c4dconst sheet = SpreadsheetApp.getActiveSheet(); const data = sheet.getDataRange().getValues(); var START_ROW = 1 var START_COL = 1 function getSheetValue(sheet) { // 1行目(フィールド)の値を取得 var fieldRange = sheet.getRange(1, 1, 1, sheet.getLastColumn()); var fieldRowValues = fieldRange.getValues(); var fields = fieldRowValues[0]; var rowValues = []; var jsonArray = []; var range = sheet.getRange(START_ROW, START_COL, sheet.getLastRow() - START_ROW + 1, sheet.getLastColumn()) var values = range.getValues(); for(var i = 0; i < values.length; i++) { // 1行のデータをpush var rowValue = values[i]; var json = new Object(); for(var j = 0; j < fields.length; j++) { json[fields[j]] = rowValue[j] } jsonArray.push(json) } return jsonArray; } function firestoreCertification() { var certification = { "email": "{client_emailの値}", "key": "-----BEGIN PRIVATE KEY-----\n{private_keyの値}\n-----END PRIVATE KEY-----\n", "projectId": "{project_idの値}" } return certification; } function createFirestoreDocuments() { var arrayData = getSheetValue(sheet); arrayData.forEach(createDocument) } function createDocument(item) { var certArray = firestoreCertification(); var firestore = FirestoreApp.getFirestore(certArray.email, certArray.key, certArray.projectId); firestore.createDocument('HotelList',item); }これでcreateFirestoreDocuments関数を実行することでFireStoreに書き込みが行われます。
スプレッドシートに記入していた内容をドキュメントとして書き込むことができました!
新たにスプレッドシートに追加で書き込んでFireStoreにドキュメントを追加したい場合は//新たに追加し始める行 //ex.新たに10行目からスプレッドシートに書き込んだ場合 var START_ROW = 10上記のコードの場合
START_ROW
の値を変えることで可能です。CloudFunctionsで全体に書き込みしたい
上記のスプレッドシートの画像を見ていただけると分かるのですが、2枚目のホテルの詳細が書いてあるスプレッドシートのフィールドにhotelIdがあると思います。
このhotelIdはHotelListコレクションのドキュメントIDを値としています。
※HotelListはスプレッドシート画像1枚目のデータをFireStoreに書き込んだコレクションです。
上記の画像のように該当するhotelIdをクエリして詳細ページに遷移しています。
HotelDetailModel.swiftclass HotelDetailModel { var items = [HotelDetailItem]() weak var delegate: HotelDetailModelDelegate? func fetchHotelDetail(with hotelId: String) { let query = Firestore.firestore().collection("HotelDetail").whereField("hotelId", isEqualTo: hotelId) query.getDocuments { [weak self] (snapshot, error) in guard let `self` = self else { return } if let snapshot = snapshot { self.items = snapshot.documents.map {HotelDetailItem(from: $0.data(), docId: $0.documentID)} } self.delegate?.didFetchHotelDetail(with: error) } } }※一部抜粋
前置き大変長くなって申し訳ありません。とにかくHotelDetailそれぞれのドキュメントに該当するhotelIdをCloudFunctionsでまとめて追加したいという話です…笑TypeScriptで実装
導入はこちらを参考にしました。
https://qiita.com/star__hoshi/items/7dcf5970d28a7ff239fb
以下hotelIdを追加するために書いたコードです。index.tsimport * as functions from 'firebase-functions'; import * as admin from 'firebase-admin' admin.initializeApp(functions.config().firebase) const db = admin.firestore() type DocumentReference = admin.firestore.DocumentReference interface HotelItem { readonly address: String readonly area: String readonly imageUrl: String readonly name: String readonly restMin: number readonly stayMin: number ref: DocumentReference } interface HotelDetail { readonly address: String readonly name: String readonly access: String readonly callNumber: number readonly hotelUrl: String readonly roomNum: number readonly imageUrlList: String readonly restPlan: String readonly stayPlan: String hotelId: String } export const requestHotelIdGain = functions.region('asia-northeast1').https.onRequest(async (req, res) => { try { const result = await fetchHotelId() return res.status(200).send(result) } catch (error) { return res.status(400).send(`Failed ${error}`) } }) async function fetchHotelId() { const hotelListQuery = db.collection('HotelList') const hotelDetailQuery = db.collection('HotelDetail') const hotelListSnap = await hotelListQuery.get() const hotelDetailSnap = await hotelDetailQuery.get() const batch = db.batch() hotelListSnap.docs.forEach(doc => { const hotelItem = doc.data() as HotelItem hotelDetailSnap.docs.forEach(detailDoc => { const hotelDetail = detailDoc.data() as HotelDetail if (hotelItem.name === hotelDetail.name) { batch.update(detailDoc.ref,{hotelId: doc.id}) } }) }) await batch.commit() return new Promise<string>(resolve => resolve(`yeahhh`)) }やったことはHotelListとHotelDetailのコレクションを取ってきて、
それぞれforEachで一つ一つのドキュメントにif (hotelItem.name === hotelDetail.name)
で同じホテルの名前であったときに、一致したHotelDetailのドキュメントに一致したHotelListのdocumentIdを書き込んでいます。
batch.update(detailDoc.ref,{hotelId: doc.id})
無事hotelIdフィールドに該当するdocumentIdが追加されました!!最後に
最後まで見ていただきありがとうございます!!
間違っている点ありましたらご指摘していただけると幸いです!
他にも苦戦した点も後々書こうかと思います!
正直、今回のアプリは使う場面も限られており且つ、多くの人に使ってもらえるかは…ですが、今後も自分で作っていきたいなと思いました!!
- 投稿日:2020-07-26T17:16:10+09:00
【iOSアプリ開発】Macのloalhostに実機(iOS)から接続する方法
iOSアプリ開発をしていて、サーバサイドとの連携機能を作成するにあたって、
Macのlocalhostに実機(iOS)から接続する方法を調べました。はじめに
Mac上に立てたlocalhostに対して実機(iOS)のアプリから接続する方法と、
iOSシュミレータから接続する方法になります。対象バージョン
- Xcode ver.11.6
- Simulater ver.13.6
- iOS ver 13.5.1
http接続できるように設定
- はじめにhttp接続できるようにInfo.plistに設定を追加します。
- App Transport Security SettingのAllow Arbitrary LoadsをYESにします。
(もしApp Transport Security Settingが表示されていなければ追加すれば大丈夫です。)iOSシュミレータから接続する方法
iOSシュミレータから接続するだけならHTTPリクエストするURLはhttp://localhost (ポート)で大丈夫です。
iOS実機から接続する方法
iOS実機から接続するにはiOSシュミレータと同じhttp://localhost (ポート)では接続できません。
localhostの部分をMacのIPアドレスにする必要があります。
MacのIPアドレスの確認方法は以下になります。(すみません、言語設定が英語になっています。)
1. Macの設定
2. ネットワーク
3. 接続されているネットワークのIPアドレス(赤い部分がMacのIPアドレスになります。)
まとめ
意外に簡単に接続することができました。
これでサーバサイドのプログラムをサーバにアップしなくても連携を確認することができます。
- 投稿日:2020-07-26T14:36:43+09:00
Swiftで楽天レシピAPIを表示させてみた。
やること
Swiftで楽天APIをデバッグエリア表示させる。
完成形
APIからデータを取得する
今回使ったAPI
楽天レシピカテゴリ別ランキングAPI
(楽天IDが必須になります){ result: [ { foodImageUrl: "https://image.space.rakuten.co.jp/d/strg/ctrl/3/34d4ce95b8674c8fb6c8f08b5115464a9f180c31.17.2.3.2.jpg", recipeDescription: "小鉢がもう1品ほしいなっていう時に簡単でオススメです。", recipePublishday: "2011/08/22 19:04:07", shop: 0, pickup: 1, recipeId: 1200002403, nickname: "JIMA88", smallImageUrl: "https://image.space.rakuten.co.jp/d/strg/ctrl/3/34d4ce95b8674c8fb6c8f08b5115464a9 f180c31.17.2.3.2.jpg?thum=55", recipeMaterial: [ "きゅうり", "ごま油", "すりごま", "鶏ガラスープのもと", "ビニール袋" ], recipeIndication: "5分以内", recipeCost: "100円以下", rank: "1", recipeUrl: "https://recipe.rakuten.co.jp/recipe/1200002403/", mediumImageUrl: "https://image.space.rakuten.co.jp/d/strg/ctrl/3/34d4ce95b8674c8fb6c8f08b5115464a9f180c31.17.2.3.2.jpg?thum=54", recipeTitle: "1分で!うまうま胡麻キュウリ" }Codableを使ってJSONを変換させる
今回は上記の楽天レシピAPIから記事のフードイメージurlとレシピタイトルを取得しました。
struct ResultList: Codable { let result: [User] struct User: Codable { let foodImageUrl :String let recipeTitle :String } }APIをURLSessionを使って叩く
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() getRApi() } private func getRApi(){ guard let url = URL(string: "ここに楽天APIのURLを") else {return} let task = URLSession.shared.dataTask(with: url) { (data, response, err)in if let err = err { print("情報の取得に失敗しました。:", err) return } if let data = data{ do{ let resultList = try JSONDecoder().decode(ResultList.self, from: data) print("json: ", resultList) }catch(let err){ print("情報の取得に失敗しました。:", err) } } } task.resume() } }実際にデバッグエリア
無事表示されました。
次はtableviewに表示させてみたいと思います!
- 投稿日:2020-07-26T13:21:46+09:00
超簡単にViewの背景をぼかす方法【iOS開発】
背景をぼかしたい!!
背景をぼかしたいなーと思った時にはUIVisualEffectViewを使うと思うんですけど、ぼかす処理を毎回書くのがめんどくさい!!
というわけで超簡単に背景ぼかしができるextensionを作りましょうextensionを書く!
新しいファイルを作ってこれを書きましょう!
addVisualEffect.swiftimport UIKit extension UIView { func addVisualEffect() { //スクリーンサイズ取得! let width = UIScreen.main.bounds.size.width let height = UIScreen.main.bounds.size.height //ブラーエフェクト生成! let blurEffect = UIBlurEffect(style: .light) //ブラーエフェクトからエフェクトビューを生成! let visualEffectView = UIVisualEffectView(effect: blurEffect) //エフェクトビューのサイズ指定! visualEffectView.frame = CGRect(x: 0, y: 0, width: width, height: height) //もとのViewにaddSubView!! self.addSubview(visualEffectView) //重ね順を一番下に!! self.sendSubviewToBack(visualEffectView) } }ポイントはサイズをしっかり合わせるところと重ね順を一番下にすることですかね!
あ、そういえばブラーエフェクトには種類があるんですよ
(ここに詳しく書いてありました! -> https://dev.classmethod.jp/references/ios8-uivisualeffectview/ )
extraLightとlightとdartの3種類です!
だから引数を指定してあげればもっと使い勝手良くなる気がします
僕はlightしか使わないのでこれでいきます!実際に使う!
ViewController.swiftoverride func viewDidLoad() { super.viewDidLoad() self.view.addVisualEffect() }たったこれだけで背景をぼかすことができるんです!!便利!!
最後に
メモ程度の記事に最後までつきあってくれてありがとうございます
是非使ってみてください!!環境:Xcode11.6(11E708)
- 投稿日:2020-07-26T12:37:45+09:00
【SwiftUI】VectorArithmetic を自作して AnimatablePair の型パラメータ地獄から解脱する
はじめに
モチベーション
SwiftUI でアニメーション可能なシェイプを作成する場合は
animatableData
を実装する必要があります。アニメーション可能なパラメータが1つの場合は次のような実装をします。
public struct HogeShape: Shape { public var foo: CGFloat public var animatableData: CGFloat { get { foo } set { foo = newValue } } public func path(in rect: CGRect) -> Path { // foo をパラメータにした Path を作る } }
animatableData
の型は必ずしもCGFloat
である必要はなく、VectorArithmetic
に適合していれば OK です。次の型が既知で適合しています。
- CGFloat
- Double
- Float
- AnimatablePair
- EmptyAnimatableData
AnimatablePair
を利用することで、次のようにパラメータを2つ指定することができます。public struct FugaShape: Shape { public var foo: CGFloat public var bar: CGFloat public var animatableData: AnimatablePair<CGFloat, CGFloat> { get { AnimatablePair(foo, bar) } set { foo = newValue.first bar = newValue.second } } public func path(in rect: CGRect) -> Path { // foo, bar をパラメータにした Path を作る } }
AnimatablePair
の型パラメータには、任意のVectorArithmetic
を指定することが可能なので、型パラメータにAnimatablePair
を指定することができます。これを利用してパラメータを3つにすることができます。
public struct PiyoShape: Shape { public var foo: CGFloat public var bar: CGFloat public var baz: CGFloat public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>> { get { .init(foo, .init(bar, baz)) } set { foo = newValue.first bar = newValue.second.first baz = newValue.second.second } } public func path(in rect: CGRect) -> Path { // foo, bar, baz をパラメータにした Path を作る } }
AnimatablePair
の型パラメータにAnimatablePair
を設定していくことで、いくらでもアニメーション可能なプロパティを追加することができますが、型パラメータがネストされていくので可読性が落ちていきます。パラメータを5つにすると次のようになります。var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat, AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>>> { get { .init(prop0, .init(prop1, .init(prop2, .init(prop3, prop4)))) } set { prop0 = newValue.first prop1 = newValue.second.first prop2 = newValue.second.second.first prop3 = newValue.second.second.second.first prop4 = newValue.second.second.second.second } }プロパティが5つあることが、現実の解決すべき問題として多いか少ないかについては何とも言えないところですが、単純なシェイプでも柔軟にアニメーションをつけようとすると、プロパティの数はそれなりに必要になってくると思います。
たとえば、角を丸くした長方形のシェイプで考えてみます。
角丸の半径に加えて、各四隅の角丸の有無を自由に制御できるようにすると、単純なシェイプでも5つのプロパティが必要になります。
これらが自由に制御できれば、ビューのマスク処理にアニメーションをつけることも容易になります。
本記事では、VectorArithmetic
に適合したAnimatableValues
を自作することで、上記のネストを次のようなシンプルな実装にします。public var animatableData: AnimatableValues { get { .init(prop0, prop1, prop2, prop3, prop4) } set { let values: [CGFloat] = newValue.values() prop0 = values[0] prop1 = values[1] prop2 = values[2] prop3 = values[3] prop4 = values[4] } }環境
- Xcode 11.6 (11E708)
- Swift 5.2.4
VectorArithmetic を実装する
アニメーション可能なプロパティをフラットに複数保持するために Array で値を管理します。
public struct AnimatableValues: VectorArithmetic { private var values: [Double] }あとは、VectorArithmetic に適合するために必要な実装をします。
必要最低限の実装
AnimatableValues
をVectorArithmetic
に適合するためには、ベクトル空間の公理系を満たす必要があり、以下のように実装します。zero
static var zero: Self { get }ゼロ元を返す必要があります。ゼロ元はどのような
AnimatableValues
と加算してもプロパティを変更しないように実装します。
+
演算子static func + (lhs: Self, rhs: Self) -> Self各要素ごとに加算したオブジェクトを返す必要があります。
-
演算子static func - (lhs: Self, rhs: Self) -> Self各要素ごとに減算したオブジェクトを返す必要があります。
scale(by:)
mutating func scale(by rhs: Double)各要素ごとに
rhs
倍したオブジェクトを返す必要があります。magnitudeSquared
var magnitudeSquared: Double { get }各要素の二乗した和を返します。
実装例
上記を満たすような実装例です。
固定長のリストであれば
.zero
は[0, 0, ..., 0]
のような実装が望ましいところですが、Array で保持している都合で実現できないので、空のリスト[]
で代替しておき、+
,-
演算を実行するタイミングでゼロ埋めしているのが実装のポイントです。これで公理系を満たすことができます。import SwiftUI import enum Accelerate.vDSP public struct AnimatableValues: VectorArithmetic, Hashable { private var values: [Double] public init(values: [Double]) { self.values = values } public init<F: BinaryFloatingPoint>(values: [F]) { self.init(values: values.map(Double.init)) } public init<F: BinaryFloatingPoint>(_ values: F...) { self.init(values: values.map(Double.init)) } public func values<F: BinaryFloatingPoint>(_ type: F.Type = F.self) -> [F] { values.map(F.init) } } // MARK: - VectorArithmetic public extension AnimatableValues { mutating func scale(by rhs: Double) { values = vDSP.multiply(rhs, values) } var magnitudeSquared: Double { vDSP.sum(vDSP.multiply(values, values)) } } // MARK: - AdditiveArithmetic public extension AnimatableValues { static var zero: Self { .init(values: []) } static func + (lhs: Self, rhs: Self) -> Self { .init(values: operate(vDSP.add, lhs, rhs)) } static func - (lhs: Self, rhs: Self) -> Self { .init(values: operate(vDSP.subtract, lhs, rhs)) } private static func operate(_ operation: ([Double], [Double]) -> [Double], _ lhs: Self, _ rhs: Self) -> [Double] { let count = max(lhs.values.count, rhs.values.count) let lhs = lhs.values + Array(repeating: 0, count: count - lhs.values.count) let rhs = rhs.values + Array(repeating: 0, count: count - rhs.values.count) return operation(lhs, rhs) } }この実装によって、
animatableData
は次のように簡潔に記述できるようになります。public var animatableData: AnimatableValues { get { .init(prop0, prop1, prop2, prop3, prop4) } set { let values: [CGFloat] = newValue.values() prop0 = values[0] prop1 = values[1] prop2 = values[2] prop3 = values[3] prop4 = values[4] } }
- 投稿日:2020-07-26T11:07:38+09:00
Dateを扱うときは12時間表記設定を考慮しよう。
要約
- カスタムフォーマットの日時文字列からDate変換時にnilが返却される。
- シミュレータでは発生しない、実機で発生する
- 国内のみのサービスでもDateFormatterにはLocaleは必ず設定し利用する。
- 可能であれば実機利用のテストのタイミングに組み込む
現象
文字列からDate変換するときに、端末の設定が12時間表記になっていると必ずnilが発生。
シミュレータでは発生しないので、忘れがち。問題になるパターン
変換文字列 24時間 12時間 シミュレータ 時刻を含む文字列 成功 失敗(nil) 成功 時刻を含まない文字列 成功 成功 成功 参考 問題になるコード
let dateFormmater = DateFormatter() dateFormmater.dateFormat = "yyyy-MM-dd HH:mm:ss" /// 12時間表記の際に date == nil となる. let date = dateFormmater.date(from: "2020-03-02 10:00:00")対処方法
Formatter設定時Localeを設定する
実機でもdateFormatterにはLocaleが設定されているものの、localeを改めて設定する必要がある。
let dateFormmater = DateFormatter() dateFormmater.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormmater.locale = Locale(identifier: "en_US_POSIX") /// 12時間表記の際でもnilとならない。 let date = dateFormmater.date(from: "2020-03-02 10:00:00")忘れがちなのでExtenstionにしてしまう.
状況によって固定値も含めてしまうとよい。
public extension DateFormatter { static var standard: DateFormatter { let standard = DateFormatter() standard.dateFormat = "yyyy-MM-dd HH:mm:ss" standard.locale = Locale(identifier: "en_US_POSIX") return standard } }コードで強力に制約をするのであれば、DateFormatterをwrapするのが良さそう。
おまけ
海外対応時のメッセージの受信時刻表示で必要なこと
- 時刻データのタイムゾーンとクライアントのタイムゾーンを考慮する
- 時刻表記のフォーマットを考慮する(ここでは触れていない)
let dateFormmater = DateFormatter() dateFormmater.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormmater.locale = Locale(identifier: "en_US_POSIX") /// 時刻データのタイムゾーンでDateへ変換. dateFormatter.timeZone = TimeZone(abbreviation: "JST") let date = dateFormmater.date(from: "2020-03-02 10:00:00") /// 端末のタイムゾーンで文字列に変換. dateFormatter.timeZone = TimeZone.current let dateString = dateFormatter.string(from: date!)
- 投稿日:2020-07-26T05:01:15+09:00
<Swift>RxDataSourcesでヘッダ付きのUICollectionViewを実装
完成イメージ
パターン1(全てのセクションで同じ名前、同じデータ形式を表示する場合)
TodolistDetailViewModel.swiftimport Foundation import RxSwift import RxCocoa import RxDataSources struct SectionOfTodolist { // headerはSectionModelTypeに設定されない。 // 任意の文字列を設定しておき、ヘッダーセルを作るときに参照するために定義。 // 参照の仕方:dataSource.sectionModels[indexPath.section].header var header: String var items: [Item] } extension SectionOfTodolist: SectionModelType { typealias Item = Todo init(original: SectionOfTodolist, items: [SectionOfTodolist.Item]) { self = original self.items = items } } class TodolistDetailViewModel { let items = BehaviorRelay<[SectionOfTodolist]>(value: []) func updateItems() { let todolist = getTodolist() let sections = convertToSectionModels(srcArray: todolist.todoArray) items.accept(sections) } private func getTodolist() -> Todolist { // ダミーデータ var todoArray: [Todo] = [] todoArray.append(Todo(title: "テストTODO1", expireDate: Date())) todoArray.append(Todo(title: "テストTODO2", expireDate: Date())) todoArray.append(Todo(title: "テストTODO3", expireDate: Date())) let ret = Todolist(name: "ダミーTODOリスト", color: .blue, todoArray: todoArray) return ret } private func convertToSectionModels(srcArray: [Todo]) -> [SectionOfTodolist] { let section = SectionOfTodolist(header: "test header" , items: srcArray) let section2 = SectionOfTodolist(header: "test header2" , items: srcArray) return [section, section2] } }TodolistDetailViewController.swiftimport UIKit import RxSwift import RxDataSources class TodolistDetailViewController: UIViewController { @IBOutlet weak var collectionView: UICollectionView! private lazy var dataSource = RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>( configureCell: configureCell ,configureSupplementaryView: titleForHeaderInSection) // カスタムセルを設定 private lazy var configureCell: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureCell = { [weak self] (_, collectionView, indexPath, todo) in guard let strongSelf = self else { return UICollectionViewCell() } return strongSelf.todolistCell(indexPath: indexPath, todo: todo) } // セクションヘッダータイトルを設定 private lazy var titleForHeaderInSection: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureSupplementaryView = { [weak self] (dataSource, collectionView, kind, indexPath) in guard let strongSelf = self else { return UICollectionReusableView() } return strongSelf.headerCell(indexPath: indexPath, kind: kind) } private var viewModel: TodolistDetailViewModel! private let todolistId: Int private let disposeBag = DisposeBag() init(todolistId: Int) { self.todolistId = todolistId super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setupViewController() setupCollectionView() setupViewModel() } } extension TodolistDetailViewController { private func setupViewController() { self.navigationItem.title = "ダミーTODOリスト" self.view.backgroundColor = .systemBlue } private func setupCollectionView() { collectionView.contentInset.top = TodolistDetailCollectionViewCell.cellMargin collectionView.register(UINib(nibName: "TodolistDetailCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: "TodolistDetailCollectionViewCell") collectionView.register( UICollectionReusableView.self , forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader , withReuseIdentifier: "Section") collectionView.rx.setDelegate(self).disposed(by: disposeBag) // ヘッダサイズを指定 let flowLayout = UICollectionViewFlowLayout() flowLayout.headerReferenceSize = CGSize(width: self.collectionView.frame.width, height: 40) collectionView.collectionViewLayout = flowLayout // タップ時のイベント collectionView.rx.itemSelected .map { [weak self] indexPath -> Todo? in return self?.dataSource[indexPath] } .subscribe(onNext: { [weak self] item in guard let item = item else { return } self?.presentTodoDetaillViewController() }) .disposed(by: disposeBag) } private func setupViewModel() { viewModel = TodolistDetailViewModel() viewModel.items .asDriver() .drive(collectionView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) viewModel.updateItems() } private func todolistCell(indexPath: IndexPath, todo: Todo) -> UICollectionViewCell { if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TodolistDetailCollectionViewCell", for: indexPath ) as? TodolistDetailCollectionViewCell{ cell.update(todoTitle: todo.title, todoExpireDate: todo.expireDate) // チェックボックスタップ時 let checkButtonObservable = cell.checkButton.rx.tap.asObservable() checkButtonObservable.subscribe( onNext: { [weak self] in cell.isCheck = !cell.isCheck let image = cell.isCheck ? UIImage(systemName: "checkmark.square") : UIImage(systemName: "square") cell.checkButton.setImage(image, for: .normal) } ).disposed(by: cell.disposeBag) return cell } return UICollectionViewCell() } private func headerCell(indexPath: IndexPath, kind: String) -> UICollectionReusableView { let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Section", for: indexPath) headerView.backgroundColor = .yellow let label = UILabel(frame: CGRect(x:0, y:0, width:self.collectionView.frame.width, height:20)) label.textColor = UIColor.black label.textAlignment = .center label.text = dataSource.sectionModels[indexPath.section].header headerView.addSubview(label) return headerView } private func presentTodoDetaillViewController() { let todoId = 0 let vc = TodoViewController(todoId: todoId) self.navigationController?.pushViewController(vc, animated: true) } } extension TodolistDetailViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { let item = dataSource[section] return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let item = dataSource[indexPath] let width = collectionView.bounds.width - (TodolistDetailCollectionViewCell.cellMargin * 2) return CGSize(width: width, height: TodolistDetailCollectionViewCell.cellHeight) } }■参考にしたサイト
http://m.hangge.com/news/cache/detail_2005.html
https://www.jianshu.com/p/8b9cb8d57eddパターン2(セルに表示するデータ形式をセクションによって変える場合)
※例:(パターン1からの差分のみ記載)
TodolistDetailViewModel.swiftextension SectionOfTodolist: SectionModelType { // <変更> typealias Item = TodolistItem ...省略 } // <追加> enum TodolistItem { case todo(todo: Todo) case memo(memo: Memo) } class TodolistDetailViewModel { ...省略 func updateItems() { let todolist = getTodolist() // <変更> let sections1 = convertTodoToSectionModels(srcArray: todolist.todoArray) let sections2 = convertMemoToSectionModels(srcArray: memolist.memoArray) items.accept([sections1, sections2]) } // <追加> private func convertTodoToSectionModel(srcArray: [Todo]) -> SectionOfTodolist { let items = srcArray.map { TodolistItem.todo(todo: $0) } let section = SectionOfTodolist(header: "TODOカテゴリー" , items: items) return section } // <追加> private func convertMemoToSectionModel(srcArray: [Memo]) -> SectionOfTodolist { let items = srcArray.map { TodolistItem.memo(memo: $0) } let section = SectionOfTodolist(header: "MEMOカテゴリー" , items: items) return section } }TodolistDetailViewController.swiftclass TodolistDetailViewController: UIViewController { ...省略... // カスタムセルを設定 private lazy var configureCell: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureCell = { [weak self] (_, collectionView, indexPath, item) in guard let strongSelf = self else { return UICollectionViewCell() } switch item { // <変更> case .todo(let todo): return strongSelf.todolistCell(indexPath: indexPath, todo: todo) case .memo(let memo): return strongSelf.todolistCell(indexPath: indexPath, todo: memo) } } ...省略 private func setupCollectionView() { ...省略 // タップ時のイベント collectionView.rx.itemSelected // <変更> .map { [weak self] indexPath -> TodolistItem? in return self?.dataSource[indexPath] } .subscribe(onNext: { [weak self] item in guard let item = item else { return } switch item { // <変更> case .todo(let todo): self?.presentTodoDetaillViewController() case .memo(let memo): self?.presentTodoDetaillViewController() } }) .disposed(by: disposeBag) } ...省略 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let item = dataSource[indexPath] switch item { // <変更> case .todo: let width = collectionView.bounds.width - (TodolistDetailCollectionViewCell.cellMargin * 2) return CGSize(width: width, height: TodolistDetailCollectionViewCell.cellHeight) case .memo: let width = collectionView.bounds.width - (TodolistDetailCollectionViewCell.cellMargin * 2) return CGSize(width: width, height: TodolistDetailCollectionViewCell.cellHeight) } } }パターン3(セルに表示するデータ形式をセクションによって変える + セクションによってヘッダのタイトルを変える場合)
※例(パターン2からの差分のみ記載)
TodolistDetailViewModel.swiftstruct SectionOfTodolist { // 変更 var model: TodolistSection var items: [Item] } // 追加 enum TodolistSection { case category(name: String) } class TodolistDetailViewModel { ...省略 private func convertTodoToSectionModel(srcArray: [Todo]) -> SectionOfTodolist { let items = srcArray.map { TodolistItem.todo(todo: $0) } // <変更> let section = SectionOfTodolist(model: .category(name: "TODOカテゴリー"), items: items) return section } private func convertMemoToSectionModel(srcArray: [Memo]) -> SectionOfTodolist { let items = srcArray.map { TodolistItem.memo(memo: $0) } // <変更> let section = SectionOfTodolist(model: .category(name: "MEMOカテゴリー"), items: items) return section } }TodolistDetailViewController.swiftclass TodolistDetailViewController: UIViewController { ...省略... // セクションヘッダータイトルを設定 private lazy var titleForHeaderInSection: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureSupplementaryView = { [weak self] (dataSource, collectionView, kind, indexPath) in guard let strongSelf = self else { return UICollectionReusableView() } // <変更> let sectionOfTodolist = dataSource[indexPath.section] switch sectionOfTodolist.model { case .category: return strongSelf.headerCell(indexPath: indexPath, kind: kind) } } private func headerCell(indexPath: IndexPath, kind: String) -> UICollectionReusableView { let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) headerView.backgroundColor = .yellow let label = UILabel(frame: CGRect(x:0, y:0, width:self.collectionView.frame.width, height:20)) label.textColor = UIColor.black label.textAlignment = .center // <変更> let sectionOfTodolist = dataSource[indexPath.section] switch sectionOfTodolist.model { case .category(let name): label.text = name } headerView.addSubview(label) return headerView } } extension TodolistDetailViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { // <変更> let sectionOfTodolist = dataSource[section] switch sectionOfTodolist.model { case .category: return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) } } }パターン4(パターン3をSectionModelでやる場合)
※例(パターン3からの差分のみ記載)
TodolistDetailViewModel.swift// 削除 //struct SectionOfTodolist { // var header: String // var items: [Item] //} // //extension SectionOfTodolist: SectionModelType { // typealias Item = TodolistItem // // init(original: SectionOfTodolist, items: [SectionOfTodolist.Item]) { // self = original // self.items = items // } //} // 追加 typealias SectionOfTodolist = SectionModel<TodolistSection, TodolistItem>補足:SectionModelTypeとSectionModelの違い
RxDataSources/TodolistDetailViewModel.swiftpublic protocol SectionModelType { associatedtype Item var items: [Item] { get } init(original: Self, items: [Item]) }RxDataSources/SectionModel.swiftpublic struct SectionModel<Section, ItemType> { public var model: Section public var items: [Item] public init(model: Section, items: [Item]) { self.model = model self.items = items } } extension SectionModel : SectionModelType { public typealias Identity = Section public typealias Item = ItemType public var identity: Section { return model } } extension SectionModel : CustomStringConvertible { public var description: String { return "\(self.model) > \(items)" } } extension SectionModel { public init(original: SectionModel<Section, Item>, items: [Item]) { self.model = original.model self.items = items } }