2020年9月20日日曜日

Ermine をリリースしました!


 

Monthly Calendar & Widgets アプリ、Ermine(アーミン)をリリースしました!
月カレンダーのウィジェットをカスタマイズができるのが一番の特徴。
iOS 14にしてウィジェットを探している方、ぜひお試しくださいませ。無料です。


ウィジェット (iOS 14)

ウィジェットに置きたいものは、やっぱり定番の天気予報とカレンダー。両方ともAppleのアプリであるのですが、祝日が赤色にならないのが不満。

...という人、いませんか?

Ermineは、祝日が赤色になって、イベントがある日はドット・がつきます。小さいウィジェットに情報がぎゅっぎゅっと詰まっています!

イラストカレンダーは、nicovoさん とのコラボです。初コラボ!
月ごとにイラスト変わりますよー
9月のクマポイも好きなんですが、8月のウサコさんも好き。8月が一年後なのが寂しい。
コラボしようと思い立ちお話始めたのが、8月20日というギリギリのタイミングだったのに快諾してくれて感謝!


アプリのカレンダー

アプリ本体のカレンダーは、月表示のみのシンプルなもので、大事な予定に注目できるような作りになっています。
月表示のところは、枠線や背景塗りが無いスッキリ表示、祝日のイベント名は表示なし です。

私だけでデザインしたら、よくあるカレンダーアプリのように、枠線つけたり、カレンダーの色で背景塗りしたりと、ぐちゃぐちゃ入れちゃってると思うので、デザイン案をみたときは、わー!となりました。


開発のきっかけ

もともと、こよみ(Coyomi)という、同様な機能の月カレンダーをToday ウィジェットに表示できる小さなアプリを作っていたのですが、その iOS 14のウィジェット版が欲しいというのが開発を始めたきっかけ。

開発当初は、機能はウィジェットだけの予定でした。
ですが、アプリ本体にもカレンダー機能が一通り入ることになり、どんどん機能が増えていき....最後は休みなしの開発でなんとか作り上げました。間に合ってよかったー


キャラクター

アプリアイコンになっている、イメージキャラクターは、オコジョ(冬毛) です。
(オコジョは英語でErmine)

小さい、シンプル、動きが早いというイメージがアプリに合っているかなと。
オコジョ、かわゆすぎる...!気になった方は画像検索してみてください。

時間が足りなくて、アプリ内にはまだあまり登場できてないけど、今後増えていく..カモ?


SwiftUI

内部の話になりますが、このアプリは全てSwiftUIで作ってみました。
本格的に使ってみるのは初めてだったので、どうなるかな?と思っていたけど、なんとかなったみたい。
だけど大変でした。慣れていないせいもあるのだろうけど、まだ少し採用するのは早かったかな?という気もしてます。
SwiftUIの経験談はまた別途書こうと思います。



アプリを気に入ってくれましたら、レビュー、Tip よろしくお願いします!
• • •

2020年7月23日木曜日

SwiftUIのみでアプリを作れるか?

ただいま、SwiftUIだけを使って新規アプリを作ってみるチャレンジ中。
いままでSwiftUIをApple Watchの一部でしか使ってなかったので、ほぼイチから学ぶ状態ではあるが、iOS14 Day1に向けて頑張り中。

せっかく新しく作るのであれば、Multi Window / Multi Platform でも大丈夫なものを作成したい。が、まだ頭がなれなくて難しい。

SwiftUIの第一印象

現時点でのSwiftUIの感想は、
Viewを作成しなおすことを躊躇なくやることにしたんだな 
と。

いままでUIKitを使っていたときは、Viewをレイアウトして描画する処理はコストが高いので、Viewはできるだけ使いまわし、データを入れ替える という設計思想があった。
対して、SwiftUIは、Viewに対するデータは、1対1にして結合することで管理しやすくなる反面、Viewを作成する回数は増える。

その代わり、SwiftUIのViewのレイアウトの計算は、UIKitに比べてコストがかからないようになっているように感じる。
まだ把握しきれてないが、Viewのサイズは自身のViewが決定し、親Viewで決定できない感じ?StackViewのfillの指定が無いみたいな。
いまはまだ、UIKitのときのように設定できないのが、イライラしてしまうがw

とはいえ、無駄に大きなViewを何回も作成しなおすのは良くないので、データ設計はいままでよりも、しっかりやらないといけない感。

変更される可能性があるデータと、変更されないデータ。
データを保持する場所と、参照する範囲。

そして、ViewはPreviewしやすいものにしたいので、EnvironmentObjectはあまり使わないように...と考えると、考慮することは多い。

デバイスのスペックは高くなったし、macOSだとユーザがViewサイズを変えることができるので、こういう方向性になったのは納得感はある。


プログラム初心者には?

SwiftUIは、プログラム初心者が学ぶにはいい?って思ってたけど、考えが変わった。
データが変更された → Viewを変更する という手順をOSが行うので、コード量は減る。が、見えないところも多くなるので、理解は難しそう。値渡し/参照渡しの理解も必須だろうし。
BlackBoxのところでハマるのはツラい。
だけど、ViewControllerのDelegateを作る必要もなさそうだし、理解が必要なことが単純に増えるわけではない。
classよりもstructに慣れる強制力としてはいいかもしれない。


デザイナーさんとの分担は?

Flask では、UIデザインの修正を @horiuni さんに分担にしていて、いままではStoryboardの修正をお願いしている。SwiftUIではどういう形がよいのか?

Storyboardではできないデザイン設定 例えばAttributedStringとか、Button内のLabelの設定などがあるので、SwiftUIで一箇所にデザインに関することをすべて書けるのは良い。
個々の小さいViewごとにPreviewできるのも良い。
だが、Viewとデータのコードが混在するのは避けられない。

UIKitでは、画面の枠をざっくり作ってからその中のViewを整えていたけど、SwiftUIでは、反対がよさそう。つまり、小さな部品Viewをつくってから画面を作る流れ。
データクラスの枠と、そのプレビュー用データがあれば、小さな部品Viewが作れるし。

Viewとデータクラスを一つのファイルにまとめて閉じた形にすれば、他アプリへコピーしやすくなるかもしれないなぁ。

アプリ完成時に、SwiftUIへの感想がどう変わっているかが楽しみ。


• • •

2020年6月5日金曜日

iPhoneアプリのiPad対応5 - Page Sheetの位置

iPad対応 第5回、Page Sheetの位置 について。

Page Sheetの位置

iPadのように大きな画面の場合、Page Sheetには下にマージンが付く。


上図の赤枠が、Page SheetのModal画面。
iPhoneや、iPadのSplit Viewで別アプリを同時表示などの場合、下のマージンはなく、上のマージンのみある。
そのため、iPhone Onlyアプリでは下マージンの考慮ができていなかった。

それが、Keyboard表示時に、Keyboardのサイズに合わせて画面を変える場合。


上図は、Keyboardの高さ分、ボタン類を上に移動させたいパターン。
Keyboardの高さから、画面下マージンの高さを引いた分だけ移動したい。
他にも、Keyboardの高さに合わせて、画面を上にスクロールさせる等 も同じ考慮が必要と思う。

だが、Modalの view.frame.origin は 0, 0 になっていて、view.frameでは上下マージンのサイズがわからない。

マージンの取得方法

結論から言うと、UIPresentationController から取得できる。
この例だと、self.presentationController?.presentedView?.frame の値は、(156.0, 40.0, 712.0, 688.0) になっていた。

UIPresentationControllerとは、画面の表示を管理するクラス。
今回取り上げたような、画面サイズによって表示方法を変えたり、画面表示時のアニメーションを制御したりする。
カスタムしたことがある人には、おなじみ。

iPad対応 おわり

これにて iPad対応おわり!
Recipe Note ver 1.3.0 として、先程アップデート完了。

iPad対応の他に、iOS 13 で追加された、検索のUISearchTokenや、LinkのリッチViewのLPLinkView も入れて楽しんでみたので、よければお試しください ー。



• • •

2020年6月2日火曜日

iPhoneアプリのiPad対応4 - TableCell制御

iPad対応 第四弾。テーブルのセルの表示制御について。

まず、目標を設定アプリで確認。

1. Accessory Type
縦向き (Small Size) のときは DisclosureIndicator
横向き (Regular Size) のときは None

2. 選択セル
横向き (Regular Size) のときは、セルの選択状態を保持

3. Table Style
縦向き (Small Size) のときは Detailテーブルは Grouped
横向き (Regular Size) のときは Detailテーブルは Inset Grouped

3については方法はわからなかった。Styleは作成時にのみ設定可能なので、途中で変えることはできなさそうだし...方法はあるのかな?
ということで、1 と 2 を実装していく。

Accessory Type

Size Classが変わったときに、Accessory Type を切り替えるようにする。
以下のようなクラスをMasterのTableCellに設定すると、実現できる。

class SURMasterTableViewCell: UITableViewCell {
override func awakeFromNib() {
super.awakeFromNib()
updateAccessoryType()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateAccessoryType()
}
private func updateAccessoryType() {
self.accessoryType = traitCollection.horizontalSizeClass == .compact ? .disclosureIndicator : .none
}
}
Size Classは traitCollection.horizontalSizeClassで取得、変更タイミングは traitCollectionDidChange でトリガーできる。

選択セル

選択の解除

まずは、Detail が非表示になったときMasterの選択を外す処理を考える。
外す処理は、Masterの viewWillAppear に入れていくが、方法は UITableViewController のときと、UIViewController のときとで変える必要がありそう。

UITableViewControllerのときは、clearsSelectionOnViewWillAppear を設定すればOKのようだ。
UITableViewControllerのviewWillAppearの中には、選択を解除する処理が入っているみたいなので、その前に設定が必要。
override func viewWillAppear(_ animated: Bool) {
if let split = splitViewController {
self.clearsSelectionOnViewWillAppear = split.isCollapsed
}
super.viewWillAppear(animated)
}
UIViewControllerのときは、UITableView.deselectRow を組み込む。
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let split = splitViewController, split.isCollapsed {
if let indexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: indexPath, animated: true)
}
}
}
DetailViewを「何も選択していないView」を使わず、「常になにか選択されている」ようにする場合は、1カラムから2カラムに変更されたときにも選択する処理が必要。

選択しないCell


Recipeアプリの設定画面では、上図の構成になっている。
これら選択状態にしないCellについての実装方法について。

タップ不可のCell

Switchがあるタップ不可のCellの実装は、Selection を Noneにするとともに、tableView(_:shouldHighlightRowAt:) で false を返す。
見た目では、SelectionをNoneにするだけでタップ不可に見えるけど、内部では選択する処理が入っているから、shouldHighlightRowAt の処理も必要。

タップ可で選択不可のCell

ヘルプなどのCellは、TapできるけどSafariで表示するだけなので選択状態にはしないCell。
この場合は、tableView(_:shouldHighlightRowAt:) は true で返し、tableView(_:willSelectRowAt:) の中で処理を入れ、戻り値のIndexPathをnil にする。


今回のTableCellの制御は、見かけの調整なので、実装しなくても実害はないけど、ちゃんと整えると気持ちが良い。
iPadだからという話題ではなかったけど、いままでiPhoneの縦のみのアプリが多かったのもあり、あまり気にしていなかったので、良いお勉強になった。


次回につづく。

• • •

2020年5月31日日曜日

iPhoneアプリのiPad対応3 - Drag and Drop

iPad対応 第3回目は、Drag and Drop。
SplitViewで、複数のアプリを同時に表示するメリットの一つが、他アプリのデータをDrag and Dropでコピーできること。

UITextField, UITextAreaは、デフォルトでテキストのDnDに対応している様子。
今回対応中のレシピアプリでは、画像が登録できる機能があるので、他アプリから画像をDropで追加できるようにしてみた。



Web上の画像をアプリに登録する場合は、かなり楽になる!
Drag and Dropの仕組みは、iOS 11以上のUITableView, UICollectionView で実装が可能。

Dropの実装

使用するのは、UITableViewDropDelegate

func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
if session.canLoadObjects(ofClass: UIImage.self) {
return true
} else {
return false
}
}
canHandleのメソッドで、受け取り可能なClassかをチェックする。今回はUIImageのみ可能に。

Dropの処理は、以下のように実装した。
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
if coordinator.items.count == 0 { return }
let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(row: editImages.images.count, section: 0)
let cellImageWidth = tableView.frame.size.width - tableView.layoutMargins.left - tableView.layoutMargins.right
for item in coordinator.items {
let itemProvider = item.dragItem.itemProvider
if !itemProvider.canLoadObject(ofClass: UIImage.self) { continue }
itemProvider.loadObject(ofClass: UIImage.self) {(object, error) in
guard let image = object as? UIImage else { return }
let cellHeight = image.size.height * (cellImageWidth / image.size.width) + 5
let placeholder = UITableViewDropPlaceholder(insertionIndexPath: destinationIndexPath, reuseIdentifier: "Cell", rowHeight: cellHeight)
placeholder.cellUpdateHandler = { cell in
if let imageCell = cell as? SURImageTableViewCell {
imageCell.iconView.image = image
}
}
DispatchQueue.main.async {
let placeholderContext = coordinator.drop(item.dragItem, to: placeholder)
placeholderContext.commitInsertion { insertionIndexPath in
self.insertImage(image, indexPath: insertionIndexPath)
tableView.insertRows(at: [insertionIndexPath], with: .none)
placeholderContext.deletePlaceholder()
}
}
}
}
coordinator.session.progressIndicatorStyle = .none
}
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
if session.localDragSession != nil {
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
} else {
return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
}
}
UITableViewDropPlaceholder で一時的なViewを作り、処理が完了したら削除する。

Dragの実装

Dragを実装すると、外部アプリへデータをドラッグできるようになる。使用するのはUITableViewDragDelegate
itemsForBeginning で、受け渡したい情報を設定する。
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let itemProvider = NSItemProvider(object: "sample name" as NSString)
return [UIDragItem(itemProvider: itemProvider)]
}

DragDelegateとともに、tableView(_:canMoveRowAt:)tableView(_:moveRowAt:to:) などを実装すると、テーブル内のセルの順番をDnDで操作することも可能になる。
以下は、同じSection内に限りDnDで行を入れ替えることができる実装例。

func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
return true
}
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
if sourceIndexPath.section != destinationIndexPath.section { return }
SURDataManager.shared.move(from: sourceIndexPath, to: destinationIndexPath)
}
func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
if sourceIndexPath.section != proposedDestinationIndexPath.section {
var row:Int = 0
if sourceIndexPath.section < proposedDestinationIndexPath.section {
row = tableView.numberOfRows(inSection: sourceIndexPath.section) - 1
}
return IndexPath(row: row, section: sourceIndexPath.section)
}
return proposedDestinationIndexPath
}
//MARK:- UITableViewDragDelegate implements
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
return []
}
view raw TableDnD.swift hosted with ❤ by GitHub

外部アプリにデータを渡さないのであれば、itemsForBeginningの戻り値は設定なしでOK。

ただし、tableView(_:moveRowAt:to:) の処理中で、tableView.reloadSections で更新すると、次回から行移動ができなくなるバグ?があるみたいなので注意。tableView.reloadData() だと大丈夫のようだ。

iPadだと、登録がかなり楽になる! 素敵!
次回につづく。
• • •

2020年5月30日土曜日

iPhoneアプリのiPad対応2 - Readable Width

読みやすい表示幅 - Readable Width

iPhoneアプリのiPad対応。前回のSplitViewに続いて、今回は表示幅について。


画面の横幅が大きくなったとき、デフォルトのMarginサイズだと横に間延びして読みにくくなってしまう。
読みやすい表示幅を制御する仕組みはすでにOSにあり、Readable Width を設定すれば反映できる。
(iOS 12以上で利用可)

設定方法

Storyboard > Size Inspector の "Follow Readable Width" がその設定になる。

ちなみに、Preserve Superview Marginsは、SuperviewのMarginも考慮するプロパティ。カスタムテーブルセルを作るとき、標準セルのMarginと合わせることができます。

Readable Widthの幅は、OSで設定されているText Sizeによって変わるのに注意。
Text Sizeが大きくなると、幅は大きくなる。

デフォルトのMarginを無しでReadable Widthのみ反映する

レシピの画像は、Compactサイズでは左右Margin無し、Readable Widthが反応するサイズではそのMarginに反応するよう設定した。
この設定は以下の通り。

Layout Margins は 0 を設定し、Follow Readable Width にチェックをいれる。
これで、以下のように、画面幅に応じてMarginが切り替わる。


Readable Marginのサイズを取得する

UIViewのlayoutMarginsに、Readable Widthも含めたMarginが含まれている模様。
ただ、アプリ操作途中でOSのText Sizeを変えることによりReadable Widthの幅が変わった場合、layoutMargins には変更後の値は含まれていないっぽい。(iPadOS 13.5 で確認)


iPhoneアプリをiPad対応する際、Readable Marginの対応することは多くありそう。
Storyboardの設定だけで完結できるのはとても良い。

次回につづく。
• • •

iPhoneアプリのiPad対応1 - SplitView

iPadOSMac Catalyst のお勉強として、既存のiPhoneアプリ Recipe Note のiPad対応を試し中。WWDC 2020 でなにか進展がありそう予測も兼ねた復習として。
学んだこと、迷ったことを書いてみる。

OSになるべく逆らわず、以下の基本方針で。

  • iPhone/iPad で同じUI(Storyboard)を使う
  • UIDevice.currentDevice().userInterfaceIdiom でOSの条件分岐しない
  • Size Classでの条件分岐しない

UISplitViewController

iPadOSでSplit Viewの操作をすると、大きいサイズ(Regular) ⇔ 小さいサイズ(Small) が頻繁に切り替わることになる。
その操作をしても、いい感じの表示にするには、やはりUISplitViewController を使いたい。



UISplitViewController を使うポイント

状態の設定と取得

SplitViewの種類は、preferredDisplayMode で設定できる。
Master/Detailを両方表示(例: 設定アプリ)や、MasterをOverlay表示など。
isCollapsed で折りたたまれているかどうか?を取得できる。

displayModeButtonItem を、navigationItem.leftBarButtonItem に設定することにより、Back / 全画面表示 の処理をするボタンが表示できる。上記動画の右ペインの左上にあるボタンがそれ。状態によって表示も自動的に切り替わってくれる。

サイズに関する挙動差

Master(左ペイン) → Detail(右ペイン)と画面遷移していく、それらの画面がどこにStackされるのかは、Splitの表示状態によって異なる。
サイズがSmall、つまり1ペイン表示のときは、MasterのUINavigationViewControllerにStackされ、サイズがRegularのときは、UISplitViewControllerにStackされる。

iPhoneのときは初期表示をMasterのみにする

UISplitViewControllerのDetailにViewがあると、デフォルトでは初期表示はDetailになる。
iPhoneのときは初期表示をMasterのみにしたい場合は、UISplitViewControllerDelegate の splitViewController(_:collapseSecondary:onto:) で制御する。

func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
if secondaryViewController is PlaceholderViewController {
return false
} else {
return true
}
}
Masterのみにしたい場合、false を返せばOK。
このメソッドは、iPhoneの初期表示だけでなく、サイズがRegular → Smallに変わった場合にも対応する。

ちなみにMasterのViewControllerにSplitViewのDelegateを設定したい場合、delegateの設定をviewLoadでしても間に合わないので、awakeFromNibで行う。(が、SplitView継承してカスタマイズする方がよいかもしれない)

Small → Regularに変わったときのDetailの指定

func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? {
if let navigation = primaryViewController as? UINavigationController, navigation.viewControllers.count == 1 {
return UIStoryboard(name: "Placeholder", bundle: nil).instantiateInitialViewController()
} else {
return nil
}
}

上記のコードでは、MasterのUINavigationViewControllerがTopのときは「なにも設定されていないView」をDetailに設定、それ以外はデフォルト = 表示されていたViewがDetailに表示 するようにしてみている。


当初は、「なにも設定されていないView」を使わない方法でアレコレ試していたが、いろんなパターンに対応するのは大変なので、使用する形の方が素直に実装できると思った。

Tabをデフォルトの挙動に合わせる

今回、Tabの中にSplitViewを入れる形で実装を進めているのだが、そうすると動かくなった機能がある。
  1. Hide Button Bar on Push (タブを非表示にする設定)
  2. TabItemタップ時、NavigationのTopに戻る
1については、setNavigationBarHiddenを使って自前で制御するしかない予感。iPhoneのときのみタブを非表示にしようかとも思ったが、無理に制御しないほうが無難かもと思い中。

2については、UITabBarControllerDelegate で以下のコードをいれてみた。

func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
if tabBarController.selectedViewController == viewController, let split = viewController as? UISplitViewController {
if split.isCollapsed {
if let navigation = split.viewControllers.first as? UINavigationController {
navigation.popToRootViewController(animated: true)
}
} else if split.displayMode == .primaryHidden {
let mode = split.preferredDisplayMode
split.preferredDisplayMode = mode
}
}
return true
}
折りたたまれているときは、NavigationをRootに遷移。折りたたまれていない + Masterが非表示の場合には、Masterを表示する。(preferredDisplayModeにはallVisibleを設定している)

ちなみに、SplitViewのドキュメント
Split view controllers are normally installed at the root of your app’s window.
とあるので、Window Root以外にSplit Viewを使うのは推奨されていないかもしれない。


今回はここまで。
次回はSplitView以外のことを書いていこうと思う。
• • •