Pieces of Paper v3.0.0に向けて作業する
やっていきます
SwiftUIのPreviewProvider、状態変数持たせられないのかと思ってたら、staticつけたらOKなんだ。知らなかった
CollectionViewController側からこんな感じで画面遷移させられたので、安全に移行できそう
let canvas = UIHostingController(rootView: Canvas())
navigationController?.pushViewController(canvas, animated: true)
// performSegue(withIdentifier: "toCanvasView", sender: self)
元々の仕様はモーダル遷移だったけど、むしろプッシュ遷移の方が自然なことに気づいた
だいぶ捗った。
明日からは本格的にCanvasのロジック移行を行う。
とりあえずツールピッカーの機能からかな。その次にナビゲーションバーのボタンアクションで
あとNavigationBarの色変えるのが結構大変らしい。
SwiftUIにしたら透明になっちゃったので、できればグレーにしたかったが、強いこだわりではないのでこれでいいかと思っている
// MARK: -
- ドキュメントコメントMARK + 水平線
Xcodeのショートカット備忘録
- ⌘ + 0: 左端のバー隠す
- ⌘ + Shift + 0: 右端のバーを隠す
- ⌘ + Alt + Enter: SwiftUIのプレビューを隠す
Viewのイニシャライザで別プロパティを渡そうとしたら、まだイニシャライズされていませんで詰んだんだけど、defer文にしたら通った?!
struct Canvas: View {
@State var hideExceptPaper = true
var delegateBridge: DelegateBridgeObject!
init(drawing: PKDrawing = PKDrawing()) {
defer {
delegateBridge = DelegateBridgeObject(toolPicker: toolPicker, hideExceptPaper: $hideExceptPaper)
}
}
@Bindingの変数のイニシャライザ
init(_ state: Binding<Bool>) {
_selected = state
}
今はとりあえずDelegateに準拠させるためだけにDelegateBridgeObjectってクラスをつくってそこにドカドカロジック追加してるけども、
将来的にはここをViewModelに担当させると良さそうね。
ObservableObjectとして
defer文にした影響で、Delegateで渡してたオブジェクトがnilになっててApple Pencilのダブルタップが反応しなくなった
やっぱ変なことするのはよくないな……
NavigationBarItemを使う方法が見つからないと思ったら、toolbarという名前になっている模様
SF Symbolの一覧、アプリ落とすしかない?
(めんどくさい)
Canvasがあらかた出来上がった。
次回、Save/Cancelのロジックを実装したら完成かな
DismissActionがiOS 15以上なのがダルいな……
iOS 14を切れるまではこっちでやる
@Environment(\.presentationMode) var presentationMode
presentationMode.wrappedValue.dismiss()
iOS 15だと、破壊的な処理があるボタンをこれで書ける
Button("Delete", role: .destructive, action: delete)
でもまあ.foregroundColor(.red)
でも一緒か
AutoSaveについて
trueをデフォルトにする。
AutoSaveを全ユーザーに適用してもいいかと思ったが、オプションで選べた方がいい。
実装コストもそこまで高くない……と思われる。
-
AutoSave: true
- Save Button: Canvas -> ListView
- 「Save」って名前はよくないな
- Delete Button: Delete a note
- ゴミ箱アイコンにするか
- Undo/Redo: via PKToolPicker
- Save Timing: canvasViewDrawingDidChange()
- Save Button: Canvas -> ListView
-
AutoSave: false
- Save Button: Actually save
- Delete Button: Delete a new note or discard change an existed note
AutoSaveの変更は設定画面(これからつくる)から行う
Canvasの移行完了。Listをつくってく
ListはUITableView的なUIでした。
LazyVGridを使うことになりそ
別にLazyじゃなくても、VStackでもよかったのかな。
まあでも遅延実行してくれるならそれに超したことはないか
あ、いや、VStackだとアレか。本当に単純に並べるだけなのか
GridItem.Sizeを3パターン試す
.fixed: 一行の要素数が固定値かつ画像は固定サイズ。論外
.flexible: 一行の要素数が固定だが、画像が縮小する。
.adaptive: 一行の要素数が可変。その分一要素の最低widthを指定する必要がある
まあ.adaptiveしか選択肢ないかな
だいぶ近づけた
Before
After
いくつかポイント
画像の扱いは改めて難しいなと思った
struct NotesGrid: View {
var drawings = [PKDrawing]()
let gridItem = GridItem(.adaptive(minimum: 250), spacing: 80.0, alignment: .center)
var body: some View {
ScrollView {
LazyVGrid(columns: [gridItem], spacing: 80.0) {
ForEach((0..<drawings.count), id: \.self) {
Image(uiImage: drawings[$0].image(from: drawings[$0].bounds, scale: 1.0))
.resizable()
.aspectRatio(1.25, contentMode: .fit)
}
.background(Color(UIColor.secondarySystemBackground))
.shadow(radius: 10.0)
}
}
}
}
GridItemのspacingが横の余白指定で、LazyVGridのspacingが縦の余白指定。
これに気づくのに結構時間がかかった
二つ不満がある。
- 左端と右端に余白がないこと
- 画像がグリッドより小さかった場合、引き伸ばしてしまうこと
上はまあ別に妥協してもいいんだけど、下がちょっと気に入らない。
下も妥協できるポイントっちゃそうなんだけど……
よく考えたら左端と右端の余白はView全体のpaddingの問題なので、↓で解決した
struct NotesGrid: View {
var body: some View {
ScrollView {
LazyVGrid(columns: [gridItem], spacing: 80.0) {
// …
}
}
.padding()
}
}
引き伸ばしちゃう問題がちょっと対応難しいから、これはもう受け入れるしかなさそう
これ、助かる
プレビューを改善したかったが、
struct NotesGrid_Previews: PreviewProvider {
static var previews: some View {
let image = UIImage(systemName: "pencil.and.outline")!
let data = image.pngData()!
let drawing = try! PKDrawing(data: data) // 変換できない
NotesGrid(drawings: [drawing])
}
}
これができなかった。
デバッグしたところ、Dataまではいけてたんで、PKDrawingの問題っぽい。
うーん……PKDrawingのDataしかデコードできないんだろうか……
UI的にはほぼ元のリストを再現できた。
画像引き伸ばし問題の違和感が大きいが、これ対処するのが難しいので、後回し
画面右下にオーバーレイする感じのボタンを配置したかったんだけど、こんな感じになった。
本当か? という感じ
let scrollToBottomButton = Image(systemName: "arrow.down.circle")
var body: some View {
ZStack {
// ……
VStack {
Spacer()
HStack {
Spacer()
scrollToBottomButton
.resizable()
.frame(width: 200.0, height: 200.0)
.padding()
}
}
}
}
.overlayモディファイアで書かないと、ScrollViewReaderのスコープの中に入れられなかったので、そっちで書き換え。
というか最初から.overlayで良かったね
SwfitUIの起動に切り替えたが、起動時にキャンバスをいきなり開く方法で悩んでいる。
リストにしてもいいんだけど、そうするとNavigationBarが出ない。
import SwiftUI
@main
struct PiecesOfPaperApp: App {
@State private var shouldShowPurple = false
var body: some Scene {
WindowGroup {
NavigationLink(
destination: NotesGrid(),
isActive: $shouldShowPurple) {
Canvas()
}
.onAppear {
shouldShowPurple = true
}
}
}
}
とりあえずデータ突っこんで表示することはできたけど、問題が色々出て、頭がこんがらがってしまった
- NavigationViewで.stackにすると一応ルートViewでもNavgationViewは表示できた
- けど大幅にレイアウトが崩れる
- WindowGroupに入れてるから?
- 起動をCanvasにして、アプリ構成としてはルートViewをListの方にしたい、と思っているが、それが難しい
- isActiveを指定することでコードで遷移できると書いてあるんだけど、これが思ったように動作しない
- NavigationView { View() } につっこむViewからNavigationLinkをはずしたら、NavigationBarは出るようになった
- 出て欲しいスタイルとはだいぶ違うが、もうこれでやっていくしかないなあ
- つまり、セクションを表示するためのViewが一つ必要になる
よし、だいぶ画面遷移については見えてきた
- NavigationView
- SectionView(maybe)
- NotesGrid
- (全画面モーダル遷移)Canvas
Canvasの表示には↓を使う
もはやCanvasではNavigationBarを出せないので、カスタムボタンを用意する
NavigationViewの使い方がわかりづらい。
公式チュートリアルがこれなので、一応こんな感じで実装する
NavigationViewを導入したが、なんか全体的に文字が大きくて困っていた
調べても情報が出てこなくて、他の記事見るとそもそもデフォルトの指定で上手いこと行っている記事ばかりだった。
変だなと思って、SwiftUIベースの新規プロジェクトつくりなおして、Infoから足りないものを持ってきたら、解消した。
下記のUILaunchScreen
を指定しないとなんかダメなよう
Buttonに引数ありの関数をactionとして渡せないじゃん……と困ってたら、
Button(action: { open(drawing: viewModel.drawings[index]) }) {
func open(drawing: PKDrawing) {
Router.shared.toggleStateValue()
Router.shared.updateDrawing(drawing: drawing)
}
こんな書き方で回避できた
この書き方だと通らなかった
init(drawing: PKDrawing = PKDrawing()) {
delegateBridge = DelegateBridgeObject(toolPicker: toolPicker, canvas: self)
// Variable 'self.delegateBridge' used before being initialized
こっちならいける
init(drawing: PKDrawing = PKDrawing()) {
delegateBridge = DelegateBridgeObject(toolPicker: toolPicker)
delegateBridge.canvas = self
とりあえず保存ロジックをつくったが、無事一ストロークごとに一ノートつくるものになった
あとGridの下スクロールボタンがめちゃくちゃ重いので、なんとかしたい
CanvasとGridでViewModelわけたら、iCloudのドキュメントオープンで暴走するときがあるので、なんか根本的につくりなおしたいな
データまわりでの疑問
- PKDrawingをpng形式でiCloud Drive上に保存できないか?
- UIDocumentって必要なのか?
- iCloud Drive上にあるpngファイルを全部取得、みたいな処理をどうやってやるのか?
- pngファイルのメタデータ、取れるか?(タグ付けを見越して)
- PKDrawingをpng形式でiCloud Drive上に保存できないか?
これは余裕でできた。
var iCloudURL: URL {
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)!
.appendingPathComponent("Documents")
.appendingPathComponent("a.png")
return url
}
func appendDrawing(drawing: PKDrawing) {
let png = drawing.image(from: drawing.bounds, scale: UIScreen.main.scale).pngData()
try! png?.write(to: iCloudURL)
}
- UIDocumentって必要なのか?
なぜか必須だと思っていたが、そうではなかった。
使うとコンフリクトの解消などがプログラムからできるっぽいので、使った方がベターには思える
ただ↑みたいにやれば、iCloud Driveへの書き込み自体はできた
- pngファイルのメタデータ、取れるか?(タグ付けを見越して)
CIImageにして取得するのを試してみた
["ProfileName": sRGB IEC61966-2.1, "Depth": 8, "DPIWidth": 144, "{TIFF}": {
ResolutionUnit = 2;
XResolution = 144;
YResolution = 144;
}, "{Exif}": {
ColorSpace = 1;
PixelXDimension = 722;
PixelYDimension = 658;
}, "PixelWidth": 722, "{PNG}": {
Chromaticities = (
"0.3127",
"0.329",
"0.64",
"0.33",
"0.3",
"0.6000000000000001",
"0.15",
"0.06"
);
Gamma = "0.45455";
InterlaceType = 0;
XPixelsPerMeter = 5669;
YPixelsPerMeter = 5669;
sRGBIntent = 0;
}, "HasAlpha": 1, "PixelHeight": 658, "ColorModel": RGB, "DPIHeight": 144]
うーんちょっと欲しい情報じゃないんだよな
画像ファイルのメタデータのことをExifデータ(イグジフ、でよさそう)と呼ぶらしい
撮影日や撮影場所、撮影条件など
普通にここに出てる情報を使うのが一番いいと思っている
これは何情報なんだ……?
よくわかってないけど、調べた限り
- メタデータを持つ方法は三つある
- 画像データ自体のExifを使う方法
-
UIDocument
(というかAppleのファイルシステムで使える)のメタデータを使う方法 - 自分で独自形式のファイルつくる
- CIImageのExifを使いたいなら、保存するときに自分で日時を入れたりする必要があるみたい
- また、タグは持たせられないと思われる
-
UIDocument
なら、NSMetadataQuery
を指定する? よくわかってない
- まあたぶん
UIDocument
のサブクラスをつくって、適宜メタデータ追加していくことになるかな〜
頑張ってExif情報出そうとしたやつ
let ciimage = CIImage(contentsOf: iCloudURL)
let properties = ciimage!.properties
let exif = properties["{Exif}"] as! [String: Any]
let date = exif[kCGImagePropertyExifDateTimeOriginal as String]
print(date)
- iCloud Drive上にあるpngファイルを全部取得、みたいな処理をどうやってやるのか?
contentsOfDirectory(atPath:)
でディレクトリ内の要素が全部取れる。
let result = try! FileManager.default.contentsOfDirectory(atPath: FileManager.default.url(forUbiquityContainerIdentifier: nil)!.appendingPathComponent("Documents").path)
print(result)
["drawings.plist", "drawings 2.plist", "a.png", ".Trash"]
この結果を.filterしたらいいかと思います
‘NSMetadataQuery’ は検索用みたい。ないかも
とりあえず方針としては見えてきたかな
- png形式でノートを保存
- UIDocumentの構造をつくりかえる
とりあえず旧フォーマットの移行は今はあまり考えずに、新しいデータモデルをつくるところからにしよう
定数からBinding値をつくる
PlayButton(isPlaying: Binding.constant(true))
func save(drawing: PKDrawing) {
let id = Date().description
let path = iCloudURL.appendingPathComponent(id + ".png")
let png = drawing.image(from: drawing.bounds, scale: UIScreen.main.scale).pngData()
try! png?.write(to: path)
}
こんな感じで保存してみた
「2021-11-23 02:11:36 + 0000.png」というファイル名になった
ミリ秒まで出したかったので、結局フォーマット指定子することにした
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ssSSSS"
let string = dateFormatter.string(from: Date())
UIDocument
、よく理解してなかったけど、これ「ファイルパス上にあるドキュメント」の抽象クラスなんだな
path/some.png
に対して、開発者がコンテンツを詰めることになるんだ
データの実体だと思ってたので、それで混乱した
PKDrawing
の相互変換について
let png = drawing.image(from: drawing.bounds, scale: UIScreen.main.scale).pngData()
これでPKDrawing -> PNG形式は実現できる
ただしPNG -> PKDrawingをやろうとすると、PKDrawingはきちんと生成できなかった
let png = drawing.dataRepresentation()
こっちでやると復元できるData形式のデータが取れる。
両方Data型なので、できるかと思っていたが、そんなことはなかった。
バイナリを見てみると、
「com.apple.ink.pen」という文字列も入っていて、何かしらの識別はしているのだろう
データを取得して、リストを生成するところをこんな感じで書いてみた
final class NotesViewModel: ObservableObject {
@Published var drawings = [PKDrawing]()
var temp = [PKDrawing]()
init() {
let allFileNames = try! FileManager.default.contentsOfDirectory(atPath: FilePath.iCloudURL.path)
let drawingFileNames = allFileNames.filter { $0.hasSuffix(".drawing") }.sorted(by: <)
let dispatchGroup = DispatchGroup()
let dispatchQueue = DispatchQueue(label: "queue")
drawingFileNames.forEach { filename in
dispatchGroup.enter()
dispatchQueue.async(group: dispatchGroup) { [weak self] in
self?.asyncTemp(filename: filename) { drawing in
defer { dispatchGroup.leave() }
DispatchQueue.main.async {
self?.temp.append(drawing)
}
}
}
}
dispatchGroup.notify(queue: .main) {
self.drawings = self.temp
}
}
func asyncTemp(filename: String, comp: @escaping (PKDrawing) -> Void) {
DispatchQueue.global().async {
let url = FilePath.iCloudURL.appendingPathComponent(filename)
guard FileManager.default.fileExists(atPath: url.path) else { return }
let document = NoteDocument(fileURL: url)
document.open() { success in
if success {
guard let data = document.drawingData,
let drawing = try? PKDrawing(data: data) else { return }
comp(drawing)
} else {
fatalError("could not open document")
}
}
}
}
非同期処理をまとめないといけないと思っていたが、まとめなくてもなんとなくいけるかも
CurrentValueSubject
を使うという手もあるんだけど、SwiftUIとの相性が良くないかも
iCloudの公式サンプル見つけた
Canvasが何度も呼ばれて無限に空ファイル作成されるので、判定入れた
init(drawing: PKDrawing) {
delegateBridge = CanvasDelegateBridgeObject(toolPicker: toolPicker)
delegateBridge.canvas = self
canvasView.delegate = delegateBridge
if !drawing.strokes.isEmpty {
canvasView.drawing = drawing
}
addPencilInteraction()
}
細かいところ気にし出すと山ほどあるけど、大枠はできたかと思う。
新規キャンバス作って、iCloud上に保存して、それをオープンして編集する、一連の流れができた。
ノートファイルは個別。
しばらく自分で試す。
今後のアクション
- drawing.plistを新形式にコンバートする
- UIDocumentの機能使ってタグ付けする
- Listのデータ読み込み部がイマイチなので、ちゃんとPub/Subをつけたい
- ソート/フィルターの実装
- ゴミ箱をつくる
2, 3がちょっと重いかな
アイコン変えたいとかスクショ変えたいとかテストコード整えたいとかは来年に持ち越すか
UIDocumentのいい資料
画像ファイルのバイナリデータは、先頭でファイル形式が判別できるらしい。
PNGは普通にPNG(0x89504E47)と書いてあるのでわかりやすい。
0x50: P, 0x4E: N, 0x47: G。
JPEGは0xFFD8FFE0
PKDrawingは0x777264F0だった。うーんさすがにわからない
なんでこんなことを調べたのかというと、PoPのファイル拡張子を考えていたから。
.drawing
だと、Sketch?のファイル形式らしく、嘘になってしまう
.pkdrawing
にしようと思う
気づいた。
UIDocument
のサブクラスをプロパティリスト形式にすれば、ノートデータとメタデータを保存することができる
昼間、歩きながら考えたことのまとめ
- iCloudからのドキュメントは最初に全部openしておく仕様にする
- というかそうしないとソートもフィルターも無理
-
VLazyGrid
にはopen()
が終わるまで、ProgressView
を出しとく
- drawings.plistのコンバートが時系列にならない問題は、タグにプログラマティックに作成時間を入れることで解決する
- 現在時刻 - 1msec * index みたいな感じ
- ノートの追加、削除はメモリ上でのみ操作して、実データの処理でエラーが出たらなんらかのアラートを出す感じ
- ソート条件、フィルター条件はこんな感じで
struct Condition {
enum sortOrder {
case .ascending, .desending
}
enum sortBy {
// プロパティ
}
enum filterBy {
// tag名
}
}
ずっとサムネイル表示がアスペクト比狂うのが気になってた
たまたま気づいたが、scaledToFit()
するタイミングをframe()
の前にするといいらしかった
Image(uiImage: noteDocument.drawing.image(from: noteDocument.drawing.bounds, scale: 1.0))
.resizable()
.scaledToFit()
.frame(width: 250.0, height: 190.0)
.background(Color(UIColor.secondarySystemBackground))
.shadow(radius: 5.0)
}
エラーをつけないといけない
- iCloudが開なかったら→ユーザーにiCloudへのアクセス許可要求 or ローカルに変更要求
- ドキュメントのopen/saveの失敗→アラート?
このままだとノートデータを全ロードすることになってパフォーマンス悪化するので、ディレクトリをInboxとArchiivedで分割することにした
終わりが見えてきた気がする
ロードマップは
メイン機能
- リロード機能をマジメにつける
- タグ機能つける
- フィルター/ソートボタンつける
- キャンバスのiボタンつくる
- 設定画面をつくる
その他
- iCloudとの同期が取れないときを考える
- コンフリクトの解消入れる
- レビュー訴求入れる
このぐらいかな
refreshableが効かないと思ったら、await functionじゃないと上手く動いてくれなさそう
List
にしか効かない、という説も俺の中である
とりあえず親コンポーネントから子コンポーネントまで全レイヤーに試してみたが、PoPだとダメだった
とりあえずNavigationBarのボタンにリロード機能つけた
private var noteDocuments = [NoteDocument]() {
didSet {
if counter <= noteDocuments.count {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.publishedNoteDocuments = self.noteDocuments.sorted { $0.entity.updatedDate < $1.entity.updatedDate }
self.isLoaded = true
}
noteDocuments.removeAll()
counter = 0
}
}
}
この書き方にしたら、SwiftUIのViewのレンダリングで配列がなくなる現象が発生した。
正解はこう
private var noteDocuments = [NoteDocument]() {
didSet {
if counter <= noteDocuments.count {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.publishedNoteDocuments = self.noteDocuments.sorted { $0.entity.updatedDate < $1.entity.updatedDate }
self.isLoaded = true
self.noteDocuments.removeAll()
self.counter = 0
}
}
}
}
メインスレッドって遅いんだなあ
UIColorが微妙にCodableじゃなかったので、ちょっと対応した
TagListをどう持たせるか考えた
App
に持たせて、子コンポーネントにパスしまくってもいいんだけど、あまり筋が良くないと感じた。
ちゃんとSubscribeさせるのがいいと思った
@EnvironmentObject を使うことで、ObservalObjectをパスすることができる
しかしこれがクラスに対して一個しかモディファイアつけられないみたいで、
↓このように同一クラスを3つ渡す、みたいなことはできなかった
@main
struct PiecesOfPaperApp: App {
@State var isAppLaunch = true
@State var isShowCanvas = false
@State var noteDocument: NoteDocument?
@StateObject var inboxNoteViewModel = NotesViewModel(targetDirectory: .inbox)
@StateObject var allNoteViewModel = NotesViewModel(targetDirectory: .all)
@StateObject var archivedNoteViewModel = NotesViewModel(targetDirectory: .archived)
var body: some Scene {
WindowGroup {
NavigationView {
SideBarList(isAppLaunch: $isAppLaunch)
.environmentObject(inboxNoteViewModel)
.environmentObject(allNoteViewModel)
.environmentObject(archivedNoteViewModel)
}
struct SideBarList: View {
@Binding var isAppLaunch: Bool
@EnvironmentObject var inboxNoteViewModel: NotesViewModel
@EnvironmentObject var allNoteViewModel: NotesViewModel
@EnvironmentObject var archivedNoteViewModel: NotesViewModel
TagListをLibrary/Application Support
に置くつもりだったけど、ここビルドごとに消しちゃうのか
iCloud上にこれに該当するディレクトリがあればいいんだけど、なさそうだなあ
ディレクトリ切るかあ
ViewモディファイアをまとめてつけたいときはGroup
が便利だな
うーんアーカイブ、アンアーカイブのロジック実装できたけど、LazyVGridの更新がめちゃくちゃになるな
パフォーマンス度外視で全更新にしてもいいけど……一旦保留
使ったことなかったけど、Arrayにdrop()ってメソッドがあった
条件に合った要素を落としたArrayを返すらしく、便利
これ、思ってたものではなかった。
条件に対して、一致が続く限り処理を続ける、という挙動だった。
filterが基本だな……
TagList
のRouterつくろうとしたらめちゃくちゃハマった。
taggingNoteDocument
が受け渡せなかった
なぜかtaggingNoteDocument
の更新見てレンダリングしたら上手くいった!?
なぜ……
@main
struct PiecesOfPaperApp: App {
@State var isAppLaunch = true
@State var isShowCanvas = false
@State var isShowTagList = false
@State var noteDocument: NoteDocument?
@State var taggingNoteDocument: NoteDocument?
var body: some Scene {
WindowGroup {
if taggingNoteDocument != nil {
// Text("Happy")
TagList(noteDocument: taggingNoteDocument)
} else {
NavigationView {
SideBarList(isAppLaunch: $isAppLaunch)
}
.fullScreenCover(isPresented: $isShowCanvas) {
NavigationView {
Canvas(noteDocument: noteDocument)
}
}
.sheet(isPresented: $isShowTagList) {
TagList(noteDocument: taggingNoteDocument)
}
.onAppear {
guard isAppLaunch else { return }
CanvasRouter.shared.bind(isShowCanvas: $isShowCanvas, noteDocument: $noteDocument)
CanvasRouter.shared.openNewCanvas()
TagListRouter.shared.bind(isShowTagList: $isShowTagList, taggingNoteDocument: $taggingNoteDocument)
DrawingsPlistConverter.convert()
isAppLaunch = false
}
}
}
}
}
do-catch文のcatchってスコープ抜けないんだ
editModeあんまり嬉しさがないな……
とりあえず雑にタグ機能をつけられた
もうちょっとUI/UXは改善したいな
それができたらソート/フィルター編かな
final class TagListToNoteViewModel: ObservableObject {
…
@Published var noteDocument: NoteDocument?
…
func add(tagName: String, noteDocument: NoteDocument) {
noteDocument.entity.tags.append(tagName)
save(noteDocument: noteDocument)
}
これだと通知されないらしい
objectwillchange
を使ってみるか
進捗
- リロード機能をマジメにつける
- タグ機能つける
- フィルター/ソートボタンつける
- キャンバスのiボタンつくる
- タグづけの導線増やす
- 設定画面をつくる
- コンフリクトの解消入れる
- レビュー訴求入れる
- UserDefaultsに設定入れる
-
iCloudとの同期が取れないときを考える
- タグのマスターファイルも取れなくなる
- Notesの描画更新ロジックをリファクタする
- キャンバスが描けなくなるときに対処する
- iCloudを拒否してるユーザーへのアラート出す
- アップデート情報の表示&初回アーカイブ時にアラート出す
- タグ削除時の処理考える←削除しない
- ダークモード切り替えのときに描画更新する
- SwiftLintのワーニングに対処する
ソートフィルターのために、popoverを使ってみた
が、しかし、期待したようなUIではなかった。
iPhoneでは普通にsheetになるよう。
せめてポイントがズレなければ使いようはあったけど、このズレ方はひどい
.navigationBarTitleDisplayMode(.inline)
、ずっと上手く機能しないと思ってたけど、子View側に書かないとダメなのか!
フィルターとソートが終わったので、次は設定関連。
まずはノートの情報を表示するところ。
理想はこんな感じで表示したいが、この前popoverが思ったのと違ったので、sheetになるかも
Group {
HStack {
Image(systemName: "plus.circle")
Text("Add a tag")
Spacer()
}
.padding(.horizontal)
TagHStack(tags: viewModel.nonFilteringTag, action: viewModel.add)
.padding(.horizontal)
}
.background(Color.gray)
Group
で背景色つけること、できないかと思ったら可能だった
ただし思ったのと違って、個別のView要素にそれぞれ背景色つけるような形になった
背景色つける順番、↓こうするとちゃんとイメージ通りになる
.background(Color.gray.opacity(0.2))
.padding()
最初逆にして、パディングしてから背景色指定したら、色部分にパディングが効いていなかった。
個人的には逆(パディングする→その範囲に背景色を指定で、背景色も含めてパディングされる)と思ったので、逆だった。
モディファイアの順番、結構大事なんだな
Canvasには.toolbar
を使ったんだけど、これiPhoneだとボタンが表示されない
.toolbar {
ToolbarItemGroup {
Button(action: { isShowActivityView.toggle() }) {
Image(systemName: "square.and.arrow.up")
}
Button(action: close) {
Image(systemName: "tray.full")
}
}
}
位置を明示的に指定しないとこうなっちゃうのかな?
イマイチnavigationBarItemsとどっち使えばいいのかわからない
あー、思い出した。
Apple DeveloperでDeprecated扱いになってたから、toolbar使ったんだ
ToolbarItemGroup(placement: .navigationBarTrailing) { … }
placementをちゃんと指定したらiPhoneでも問題なかった。
しかもpopoverの位置もちゃんと取れてる!
toolbarにちゃんと寄せていこう
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
…
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
…
}
}
こうやね
popoverの中身をlistにしたら、上手くスペース取ってくれなかった
けどHStack in VSTackとDivider駆使すればそれっぽいViewになるからこっちでつくる
ここをタップしてタグの導線にいけるようにしようと思ってたけど、今のコンポーネントのつくりだと厳しいな
うーん一旦断念する
よーしメイン機能の実装は完了した。
あとは細かいやつやバグ潰しだ
今ユーザーがiCloudに保存してるのか、ローカルなのかがわかりやすいかと思ってサイドバーに出してみたが、
State更新がサイドバーにないので、描画更新されなくて、個人的にはちょっと発見だった
つくってもいいけど、一旦ステイかなあ
@Published
をObjectWillChangePublisher
に変更したらモーダルが閉じなくなった。
var objectWillChange = ObjectWillChangePublisher()
var publishedNoteDocuments = [NoteDocument]()
var isLoaded = false
var isListConditionSheet = false {
didSet {
objectWillChange.send()
}
}
こんな感じにした。
↓も似たような話
ここがズレるようになったのはリリース後対応する
iOS版で崩れるのもあわせて
dropだとダメで、filterだとOKだった。なぜかはよくわからない……
// NG: noteDocuments = Array(noteDocuments.drop { $0.entity.id == document.entity.id })
noteDocuments = Array(noteDocuments.filter { $0.entity.id != document.entity.id })
キャンバスが閉じたとき、タグを変更したときも描画更新したいが、これをやるにはPub/Sub関係でやりたい
ただ疎結合保ったまま渡すいい方法が思いつかない……
NotificationCenterのuserInfoで強引に渡すか
雑にやるにしても、意外と大変だった。
結局この公式ドキュメントが参考になった
onReceive
を使うとSwiftUIで直接イベントを受け取ることが可能
こんなん書いてみたけど、ちょっとイマイチかも
struct NoteImage: View {
@State var noteDocument: NoteDocument
var body: some View {
Button(action: { open(noteDocument: noteDocument) },
label: {
Image(uiImage: noteDocument.entity.drawing.image(from: noteDocument.entity.drawing.bounds, scale: 1.0))
…
})
.onReceive(NotificationCenter.default.publisher(for: .updateNoteThumbnail, object: nil)) { notification in
guard let updatedNoteDocument = notification.object as? NoteDocument else { return }
if noteDocument == updatedNoteDocument {
noteDocument = updatedNoteDocument
}
}
}
あーそもそも親コンポーネントからちゃんと@Bindingすれば解決する話だった
ちょっと不具合がエグすぎて頭混乱してきたけど、大きな問題は下記の二つ
- 既存のノートを編集した後のキャンバス閉じたアクションで
NotesViewModel
の配列が飛ぶ- 2回NotificationCenterが呼ばれてる? なぜ?
- Canvasのツールピッカーが呼ぶたびに変わっている気がする
これはでも前からなってた気がする
よくNo Dataになってたもんな
インスタンス解放されてるとしか思えない、と思ってたらやっぱ解放されてる!
(lldb) po viewModel
<NotesViewModel: 0x28051b2f0>
(lldb) po viewModel
<NotesViewModel: 0x28050b5c0>
@ObservedObject
が親Viewの更新によって解放されてるっぽい
そうかSideBarで別ページ選んだときに消えてるから、@StateObject
じゃないとダメなのか
ツールピッカーのバグもたぶん@ObservedObject
起因だな
動いてるように見えるのが厄介すぎる
挙動に疑問はある
ViewModelが再作成されるんだとしたら、再ロードに行かないか?
なぜノートデータ配列だけ消去されて、フラグはリセットされていないのだろう
デバッグしてみると、再ロードに行っていたのが確認できた
No Dataになるときが再現できないからそこはよくわかんないけど……
これ結局使わなかったな
要素更新するとこのエラーが出るようになった
Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range
@Binding
でやってる子コンポーネントが悪そう
あーなるほど
↓この修正がダメだった
@Binding しちゃダメなんだ
State変数はプロパティオブザーバーが使えないので、.onChange
を使うと同等のことができる
@State var noteDocument: NoteDocument?
…
.onChange(of: noteDocument) { document in
canvasViewModel.document = document
}
ルーター部分で悩んでたけど、これでよかった。
悩んでいる最中で知ったけど、ObservedObject
はオプショナルが指定できない
struct PiecesOfPaperApp: App {
@StateObject var canvasViewModel = CanvasViewModel()
@State var noteDocument: NoteDocument? // <-もはや不要
…
CanvasRouter.shared.bind(isShowCanvas: $isShowCanvas, noteDocument: $canvasViewModel.document)
NoteのVIewModelの問題は解決
Canvasがまだ
新規作成のときにDocumentにnil渡してるんだけど、これが良くない気がしてきた
なんか考える
キャンバスが描けなくなるときに対処する
このパターン、どうも指で描けるように変更して、ツールピッカーの表示非表示を切り替えてると発生することに気づいた
toolPicker.showsDrawingPolicyControls = false
こうした。
選べてもいいとは思うけど、描画更新されなくなるバグが解消できない……
Canvas、無限スクロールをやっぱ入れようかと思う。
extension CanvasDelegateBridgeObject: PKCanvasViewDelegate {
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
updateContentSizeIfNeeded(canvasView)
guard UserPreference().enabledAutoSave else { return }
delegate?.save(drawing: canvasView.drawing)
}
private func updateContentSizeIfNeeded(_ canvasView: PKCanvasView) {
guard !canvasView.drawing.bounds.isNull else { return }
canvasView.contentSize = CGSize(width: canvasView.drawing.bounds.width * 1.2, height: canvasView.drawing.bounds.height * 1.2)
}
}
↑これはかなり雑だけど、感じはこんな感じでいけそう
ウィンドウサイズ(contentSize
)とPKDrawing
の関係は2パターン
それぞれシミュレートしてみる
1の場合
- 初期表示: ウィンドウは端末のサイズに合わせる
- 領域の追加: ウィンドウサイズ分追加する
2の場合
- 初期表示: ウィンドウは
PKDrawing
のサイズに合わせる - 領域の追加: ウィンドウサイズ分追加する
こう考えると、初期処理と追加処理はわけた方がいいっぽいな
PKDrawing、描画データがないとoriginが無限大になる仕様忘れてた
(lldb) po canvasView.drawing.bounds
▿ (inf, inf, 0.0, 0.0)
▿ origin : (inf, inf)
- x : inf
- y : inf
▿ size : (0.0, 0.0)
- width : 0.0
- height : 0.0
CanvasViewのフレーム情報が入ってくるタイミングが上手く取れないな?!
既存ノートを変更したときに描画が更新されないことに気づいた
struct NoteImage: View {
@Binding var noteDocument: NoteDocument
var body: some View { … }
前、@Binding
要らないかと思って消したのがダメだったみたい
更新が伝播されない模様
うーんここの更新が伝わらなかったのはよくわからないなあ……
親まで再作成されてるので、再作成なんだと思ってた
ピンチイン/ピンチアウト、ぜひ欲しいところなんだけど、MagnificationGesture
がちょっとしてほしい挙動ではないなあ
一応試したやつ。
- ピンチ動作をやめるとサイズが元に戻る
- 大きいノートだとそもそもピンチしない
- (動作を大きくするとピンチできる。この辺の計算もしなきゃいけないっぽいが、なんか別の方法検討した方が良さげ)
@GestureState var magnifyBy = 1.0
var magnification: some Gesture {
MagnificationGesture()
.updating($magnifyBy) { currentState, gestureState, _ in
gestureState = currentState
}
}
var body: some View {
PKCanvasViewWrapper(canvasView: $viewModel.canvasView)
.gesture(tap)
.scaleEffect(magnifyBy)
.gesture(magnification)
…
}
onChange
でやったら何か変わるかと思ってやってみた。
値は保持できるが、思った挙動にはなってくれない
@State var scale: CGFloat = 1.0
var magnification: some Gesture {
MagnificationGesture()
// .updating($magnifyBy) { currentState, gestureState, _ in
// gestureState = currentState
// }
.onChanged { newScale in
scale = newScale
}
.onEnded { _ in
scale += scale
}
}
この問題、'Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range' でクラッシュして、
原因がずっとつかめなかったんだけど、↓ここだった
var body: some View {
LazyVGrid(columns: [gridItem]) {
ForEach((0..<viewModel.publishedNoteDocuments.count), id: \.self) { index in
VStack {
publishedNoteDocuments
が空になったときは親コンポーネントから↑これが呼ばれることはないんだけど、
@ObservedObject
になっているので更新処理が走るらしく、しかもForEach
がなぜかちゃんと要素0のときに抜けてくれないっぽい
こんなのを入れたらガードできるかと思ったが、クラッシュは防げない
var body: some View {
if viewModel.publishedNoteDocuments.isEmpty {
EmptyView()
} else {
LazyVGrid(columns: [gridItem]) {
クラッシュの再現方法は、1つ以上のノートを開く、裏でFilesからデータを削除して、更新をかける
うーんバグい挙動に見えるので、正面から解決せずにワークアラウンドをすることにした。
publishedNoteDocuments
に1要素以上ある状態から、0要素になった際にクラッシュが発生するので、0要素にするのを避ける。
ViewModelの内部ではnoteDocuments
を使っているので、これを0要素にする。
No Dataかどうかの判定も、noteDocuments.isEmpty
で判定する。
ちょっと暗黙的な仕様が増えるけれど、仕方なし
↑これもあわよくばなおらないかと思ったけど、そんなことはなかった
子コンポーネントの初期化が上手く行ってないんだろうか……
コンテキストメニューからのアーカイブでクラッシュするようになってしまった……
// publish() <- crash: Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range
update()
これで逃げた
サイドバーの選択状態が消えてしまう問題も、解決できなさそう。
実用上問題はないが、ちょっと気になる
quickLookPreview(_:)
なるモディファイアがあることに気づいたが、some Viewにはないって言われちゃうな……残念
SwiftUI、アラートを複数出すことができないらしい……?
ボタンのタップ範囲が更新されなかったが、こんなのがあるらしい
.contentShape(RoundedRectangle(cornerRadius: 20))
こんな感じでモーダル出たときの二度目のonAppearをハジこうとしたらダメだった。
Canvas
もそうだったけど、どうもモーダルが出たときのスコープがおかしい気がする
.onAppear {
guard viewModel.isAppLaunch else { return }
defer {
viewModel.isAppLaunch = false
}
CanvasRouter.shared.bind(isShowCanvas: $viewModel.isShowCanvas, noteDocument: $canvasViewModel.document)
// I thought this can work, but SwiftUI cannot pass the document data...
TagListRouter.shared.bind(isShowTagList: $viewModel.isShowTagList)
viewModel.hasDrawingPlist = DrawingsPlistConverter.hasDrawingsPlist
DrawingsPlistConverter.convert()
guard didShowOnboarding else {
viewModel.showOnboarding = true
return
}
guard !UserPreference().shouldGrantiCloud else {
viewModel.iCloudDenying = true
return
}
CanvasRouter.shared.openNewCanvas()
}
ダークモード対応、UIKitのときはそれなりに大変だったけど、SwiftUIは再描画のときにcolorScheme
を状態変数として持たせたらいいのかあ
Before
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
reload()
}
}
After
@Environment(\.colorScheme) private var colorScheme: ColorScheme
private var image: UIImage {
return noteDocument.entity.drawing.image(from: noteDocument.entity.drawing.bounds, scale: 1.0)
}
SwiftLintのワーニング対応スレ
よっしゃータスク終わったア!
あとはアイコン描き変えとざっくりテスト
完全にアイコン変える流れ忘れてたので手順をメモ
- 元画像を作成
- 前回はKeynoteでやったが、今回はFigma
- AppIconはベクター画像が使えないことに気づいた
- 各種サイズの画像をつくるために下記サービスを使う
- https://makeappicon.com/
ノートのアーカイブとタグ切り替えで検索結果にノートがなかったパターンでクラッシュした。
やはり状態変数の受け渡しがまずい気がして、@ObservedObject を削って、ただのプロパティで渡す設計にしたら問題解消した……
既存ノートを編集した後で、NoteImage
が更新されないのも、PKDocument
で渡してたからっぽい
値としては同じなので、描画更新がスキップされる
正確にはPKDrawing.entity.drawing
が変更されているから、描画更新して欲しい
→drawingで指定すれば更新される
これもなんとかなるかと思ったけど、対処法が今のところEmptyView指定するくらいしかない
それはそれでNavigationBar消えて問題あったりするので、いかんともしがたい
Textの範囲が狭いから、画面いっぱいに広げてタップ範囲を奪えたら、と思ったんだけど、なんか上手く広がってくれない
frameをinfinityで指定してみたり、VStackやHStackも試してみたけど、解決しなかった
v3.0.0 テストケース
移行
- v2.1.1 -> v3.0.0 (iPad)
- v2.1.1 -> v3.0.0 (iPad) & v2.1.1 -> v3.0.0 (iPhone)
- v3.0.0 (iPad)
キャンバス
- 描画→ペンが反応しないときが一回あったが再現せず
- 保存
- ツール切り替え
- 無限スクロール
ノート
- 新規ノート作成
- 既存ノート編集
- フィルター&ソート←バグあったので修正
- フィルター&ソート
- アーカイブ&アンアーカイブ
- 更新
- ノートの他端末共有
タグ
- タグづけ
- タグの新規追加&削除
設定
- iCloud ON/OFF
- オートセーブON/OFF
- 無限スクロールON/OFF
オンボーディング
- オンボーディング初回のみ
- ダークモードでの表示更新
審査は即終了。
v3.0.0、リリース完了。