Closed179

Pieces of Paper v3.0.0に向けて作業する

CollectionViewController側からこんな感じで画面遷移させられたので、安全に移行できそう

        let canvas = UIHostingController(rootView: Canvas())
        navigationController?.pushViewController(canvas, animated: true)
//        performSegue(withIdentifier: "toCanvasView", sender: self)

元々の仕様はモーダル遷移だったけど、むしろプッシュ遷移の方が自然なことに気づいた

だいぶ捗った。
明日からは本格的にCanvasのロジック移行を行う。
とりあえずツールピッカーの機能からかな。その次にナビゲーションバーのボタンアクションで

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)
        }
    }

今はとりあえずDelegateに準拠させるためだけにDelegateBridgeObjectってクラスをつくってそこにドカドカロジック追加してるけども、
将来的にはここをViewModelに担当させると良さそうね。
ObservableObjectとして

defer文にした影響で、Delegateで渡してたオブジェクトがnilになっててApple Pencilのダブルタップが反応しなくなった
やっぱ変なことするのはよくないな……

Canvasがあらかた出来上がった。
次回、Save/Cancelのロジックを実装したら完成かな

AutoSaveについて

trueをデフォルトにする。
AutoSaveを全ユーザーに適用してもいいかと思ったが、オプションで選べた方がいい。
実装コストもそこまで高くない……と思われる。

  • AutoSave: true

    • Save Button: Canvas -> ListView
      • 「Save」って名前はよくないな
    • Delete Button: Delete a note
      • ゴミ箱アイコンにするか
    • Undo/Redo: via PKToolPicker
    • Save Timing: canvasViewDrawingDidChange()
  • AutoSave: false

    • Save Button: Actually save
    • Delete Button: Delete a new note or discard change an existed note

AutoSaveの変更は設定画面(これからつくる)から行う

Canvasの移行完了。Listをつくってく

別に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()
                }
            }
        }
    }

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
                }
        }
    }
}

https://developer.apple.com/documentation/swiftui/navigationlink

とりあえずデータ突っこんで表示することはできたけど、問題が色々出て、頭がこんがらがってしまった

  • NavigationViewで.stackにすると一応ルートViewでもNavgationViewは表示できた
    • けど大幅にレイアウトが崩れる
    • WindowGroupに入れてるから?
  • 起動をCanvasにして、アプリ構成としてはルートViewをListの方にしたい、と思っているが、それが難しい
    • isActiveを指定することでコードで遷移できると書いてあるんだけど、これが思ったように動作しない

https://developer.apple.com/documentation/swiftui/navigationlink
  • NavigationView { View() } につっこむViewからNavigationLinkをはずしたら、NavigationBarは出るようになった

  • 出て欲しいスタイルとはだいぶ違うが、もうこれでやっていくしかないなあ
  • つまり、セクションを表示するためのViewが一つ必要になる

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)
    }

こんな書き方で回避できた

https://www.fixes.pub/program/199432.html

この書き方だと通らなかった

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)
    }
  • pngファイルのメタデータ、取れるか?(タグ付けを見越して)

CIImageにして取得するのを試してみた

https://dev.classmethod.jp/articles/ios-image-metadata-from-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を使う方法
    • UIDocument (というかAppleのファイルシステムで使える)のメタデータを使う方法
    • 自分で独自形式のファイルつくる
  • CIImageのExifを使いたいなら、保存するときに自分で日時を入れたりする必要があるみたい
  • また、タグは持たせられないと思われる
  • UIDocument なら、NSMetadataQuery を指定する? よくわかってない

https://developer.apple.com/documentation/foundation/nsmetadataquery
https://developer.apple.com/documentation/foundation/icloud
  • まあたぶん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との相性が良くないかも

Canvasが何度も呼ばれて無限に空ファイル作成されるので、判定入れた

    init(drawing: PKDrawing) {
        delegateBridge = CanvasDelegateBridgeObject(toolPicker: toolPicker)
        delegateBridge.canvas = self
        canvasView.delegate = delegateBridge
        if !drawing.strokes.isEmpty {
            canvasView.drawing = drawing
        }
        addPencilInteraction()
    }

細かいところ気にし出すと山ほどあるけど、大枠はできたかと思う。
新規キャンバス作って、iCloud上に保存して、それをオープンして編集する、一連の流れができた。
ノートファイルは個別。
しばらく自分で試す。

今後のアクション

  1. drawing.plistを新形式にコンバートする
  2. UIDocumentの機能使ってタグ付けする
  3. Listのデータ読み込み部がイマイチなので、ちゃんとPub/Subをつけたい
  4. ソート/フィルターの実装
  5. ゴミ箱をつくる

2, 3がちょっと重いかな
アイコン変えたいとかスクショ変えたいとかテストコード整えたいとかは来年に持ち越すか

画像ファイルのバイナリデータは、先頭でファイル形式が判別できるらしい。
PNGは普通にPNG(0x89504E47)と書いてあるのでわかりやすい。
0x50: P, 0x4E: N, 0x47: G。

JPEGは0xFFD8FFE0

PKDrawingは0x777264F0だった。うーんさすがにわからない

なんでこんなことを調べたのかというと、PoPのファイル拡張子を考えていたから。
.drawingだと、Sketch?のファイル形式らしく、嘘になってしまう

https://www.file-extension.info/ja/format/drawing

.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だとダメだった

https://developer.apple.com/documentation/swiftui/view/refreshable(action:)?language=_2

とりあえず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させるのがいいと思った

https://developer.apple.com/forums/thread/120497

@EnvironmentObject を使うことで、ObservalObjectをパスすることができる

https://developer.apple.com/documentation/swiftui/stateobject

しかしこれがクラスに対して一個しかモディファイアつけられないみたいで、
↓このように同一クラスを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の更新がめちゃくちゃになるな
パフォーマンス度外視で全更新にしてもいいけど……一旦保留

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 を使ってみるか

最初Viewにプレゼンテーションロジックゴリゴリ書いちゃったのが良くなかった。
最初からちゃんとViewModelに仕事させればよかった

進捗

  • リロード機能をマジメにつける
  • タグ機能つける
  • フィルター/ソートボタンつける
  • キャンバスのiボタンつくる
  • タグづけの導線増やす
  • 設定画面をつくる

  • コンフリクトの解消入れる
  • レビュー訴求入れる
  • UserDefaultsに設定入れる
  • iCloudとの同期が取れないときを考える
    • タグのマスターファイルも取れなくなる
  • Notesの描画更新ロジックをリファクタする
  • キャンバスが描けなくなるときに対処する
  • iCloudを拒否してるユーザーへのアラート出す
  • アップデート情報の表示&初回アーカイブ時にアラート出す
  • タグ削除時の処理考える←削除しない
  • ダークモード切り替えのときに描画更新する
  • SwiftLintのワーニングに対処する

.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とどっち使えばいいのかわからない

ToolbarItemGroup(placement: .navigationBarTrailing) {}

placementをちゃんと指定したらiPhoneでも問題なかった。
しかもpopoverの位置もちゃんと取れてる!

toolbarにちゃんと寄せていこう

.toolbar {
    ToolbarItemGroup(placement: .navigationBarLeading) {}
    ToolbarItemGroup(placement: .navigationBarTrailing) {}
}

こうやね

popoverの中身をlistにしたら、上手くスペース取ってくれなかった
けどHStack in VSTackとDivider駆使すればそれっぽいViewになるからこっちでつくる

HStack in VSTackだと行列がガタガタになったので、VStack in HStackにした

見切れてしまうTextはScrollViewに突っこんだ。

ScrollView(.horizontal) {
    Text(document.fileURL.lastPathComponent)
}

これはかなり気持ちいい書き方

後デバッグ入れたら、余計な引数がありますってエラーが出るようになって?!となったんだけど、どうもVStackの中に入れてたViewが10を超えた模様。
Groupで囲って対応

ここをタップしてタグの導線にいけるようにしようと思ってたけど、今のコンポーネントのつくりだと厳しいな
うーん一旦断念する

よーしメイン機能の実装は完了した。
あとは細かいやつやバグ潰しだ

今ユーザーがiCloudに保存してるのか、ローカルなのかがわかりやすいかと思ってサイドバーに出してみたが、
State更新がサイドバーにないので、描画更新されなくて、個人的にはちょっと発見だった
つくってもいいけど、一旦ステイかなあ

@PublishedObjectWillChangePublisher に変更したらモーダルが閉じなくなった。

    var objectWillChange = ObjectWillChangePublisher()
    var publishedNoteDocuments = [NoteDocument]()
    var isLoaded = false
    var isListConditionSheet = false {
        didSet {
            objectWillChange.send()
        }
    }

こんな感じにした。
↓も似たような話

https://software.small-desk.com/development/2021/03/05/inheritedobservableobject-will-not-publish/

ObjectWillChangePublisherが見つからないって言われてコンパイル通らないときあるな。謎

プロパティオブザーバーをコメントアウトして、一旦コンパイルしてからだと怒られない

ここがズレるようになったのはリリース後対応する
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 じゃないとダメなのか

https://www.yururiwork.net/archives/1582

ツールピッカーのバグもたぶん@ObservedObject 起因だな
動いてるように見えるのが厄介すぎる

挙動に疑問はある
ViewModelが再作成されるんだとしたら、再ロードに行かないか?
なぜノートデータ配列だけ消去されて、フラグはリセットされていないのだろう

デバッグしてみると、再ロードに行っていたのが確認できた
No Dataになるときが再現できないからそこはよくわかんないけど……

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、無限スクロールをやっぱ入れようかと思う。

https://github.com/0si43/PiecesOfPaper/issues/63
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 がちょっとしてほしい挙動ではないなあ

https://developer.apple.com/documentation/swiftui/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
            }
    }

https://github.com/0si43/PiecesOfPaper/issues/91

この問題、'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()

これで逃げた

サイドバーの選択状態が消えてしまう問題も、解決できなさそう。
実用上問題はないが、ちょっと気になる

SwiftUI、アラートを複数出すことができないらしい……?

1つのViewに対してalertをいくつか書くと、後から書いたものがオーバーライドされるらしい。マジかよ……
まあなんとかするか

enumで出し分ける作戦にしたが、上手く出なかった。もう削除のワーニング自体出すのをやめる

こんな感じでモーダル出たときの二度目の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のワーニング対応スレ

NotificationCenter.default.publisher(for: .addedNewNote)

Trailing Closure Violation: Trailing closure syntax should be used whenever possible. (trailing_closure)

これ対応すんの無理じゃね?

multiple_closures_with_trailing_closure

↑これでめちゃくちゃ怒られてる

よっしゃータスク終わったア!
あとはアイコン描き変えとざっくりテスト

完全にアイコン変える流れ忘れてたので手順をメモ

  • 元画像を作成
    • 前回はKeynoteでやったが、今回はFigma
  • AppIconはベクター画像が使えないことに気づいた
  • 各種サイズの画像をつくるために下記サービスを使う
  • https://makeappicon.com/

ノートのアーカイブとタグ切り替えで検索結果にノートがなかったパターンでクラッシュした。
やはり状態変数の受け渡しがまずい気がして、@ObservedObject を削って、ただのプロパティで渡す設計にしたら問題解消した……

既存ノートを編集した後で、NoteImage が更新されないのも、PKDocument で渡してたからっぽい
値としては同じなので、描画更新がスキップされる
正確にはPKDrawing.entity.drawing が変更されているから、描画更新して欲しい
→drawingで指定すれば更新される

https://github.com/0si43/PiecesOfPaper/issues/88

これもなんとかなるかと思ったけど、対処法が今のところ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、リリース完了。

このスクラップは2021/12/24にクローズされました
ログインするとコメントできます