Closed81

Pieces of Paperをv3.2にアップデートする

蔀

ViewModelの中にState変数持たせるの良くないな

蔀

ScrollViewを噛ませるとどうしても左寄せになってしまう。
スペースが確保されないっぽい

HStack {
    VStack {
        // …
    }
    Divider()
    VStack() {
        ScrollView(.horizontal) {
            Text("long text")
        }
    }
    .padding()
}
蔀

ScrollView噛ませるのやめて.minimumScaleFactor(0.5)を指定するか

蔀

中途半端な対応になったので一旦閉じ

蔀

この土日でリファクタリングをやり切る

蔀
  • AppViewModelCanvasViewModel' はアプリ全体に関わるので .environmentObject` で渡している
    • CanvasViewModelCanvasView で閉じたい?
  • Canvas周りのリファクタはほぼできてるっぽい
  • NoteList部分が本丸
蔀

NotificationCenter経由でやってるノート更新受け取ってリストに追加する処理は廃止。リストは表示されたら更新かけるようにする

蔀

ObjectWillChangePublisher 使ってるところは、確か昔画面描画の順序を制御したかったからだった気がするが、@Published でできるはずなので、これも廃止する

蔀

CanvasViewModel をルートで持っているせいで、iCloud利用許可を求める前にストレージ触りに行ってクラッシュしている。
CanvasViewModelCanvasView が生成されるときに渡してあげる

蔀

iCloudからデータ取得するとき、未ソートで返ってくるから、一回全部受け取らないとソートができないんだなあ

蔀

そうかファイル名の時点でソートかけたらいいのか
ファイル名変えられたら機能しなくなるけど、原則変えないでしょうということで

inboxFileNames = inboxFileNames.filter { $0.hasSuffix(".plist") }.sorted(by: >)
archivedFileNames = archivedFileNames.filter { $0.hasSuffix(".plist") }.sorted(by: >)
``
蔀

これはまたリファクタと別件でやろう

蔀

あーページングにすることは可能だけども、ソート・フィルター機能が効かなくなってしまうな

蔀

URLをキャッシュしておいて、更新があったものだけ差分取得する仕組みするのが良さそうだ

蔀

ObjectWillChangePublisher 使ってた意図わかった
iCloudからノートデータfetchした後で並び替えるから、そのタイミングで再描画をかけたいという意図があったみたいだ
それもdocument配列を購読するのが正しいと思うので、そっちにする

蔀
  • 差分更新
  • ノート下のタグタップでタグ編集画面に飛ぶ
  • 簡単なチュートリアルつけたい
  • Swift Testing入れる
  • Xcode Cloudも有効にしたい
蔀
  • フィルター&ソート、Apply/Cancelな処理になってるのが地味にめんどくさかった
    • ApplyされるまでBindingされた値に触らずに編集する必要があった
  • よく考えたらそんなに条件多い訳じゃないので、画面操作で即時Biding変数を更新してしまう仕様に変更
蔀

@State変数は別にprivateにしなくていいかと個人的に思ってたけど、
Appleのサンプルで一個ついたの見たら気になってしまった。一応全部つけるか……

蔀
struct CanvasView: View {
    @ObservedObject private var canvasViewModel: CanvasViewModel
// …
}

CanvasView(canvasViewModel: CanvasViewModel()) <- 'CanvasView' initializer is inaccessible due to 'private' protection level

これめんどくさい
デフォルトでイニシャライザもprivateになるらしい
イニシャライザ書くか、private(set) で良いらしい

蔀

SwiftLintのワーニングが出ていて、Claudeに言われるがまま修正

# Before
if test -d /opt/homebrew/bin; then
    PATH=/opt/homebrew/bin/:$PATH
fi

if which swiftlint >/dev/null; then
  swiftlint --fix
  swiftlint
else
  echo "warning: Swiftlint not installed, download from https://github.com/realm/SwiftLint"
fi

# After
SCRIPT_OUTPUT_FILE_0="${SRCROOT}/swiftlint.result"


if test -d /opt/homebrew/bin; then
    PATH=/opt/homebrew/bin/:$PATH
fi

if which swiftlint >/dev/null; then
  swiftlint --fix > "$SCRIPT_OUTPUT_FILE_0"
  swiftlint >> "$SCRIPT_OUTPUT_FILE_0"
else
  echo "warning: Swiftlint not installed, download from https://github.com/realm/SwiftLint"
fi

蔀

unused_import は .swiftlint ファイルに

analyzer_rules:
  - unused_import

として書かなきゃいけなくなった模様

蔀

このワーニングが出てるけど、line_length の設定は間違ってなさそうなので謎
一旦放置する

Invalid configuration for 'line_length'. Falling back to default.
蔀

Preview Device指定してたのも、今Xcodeでそれなりに変えられるから、細かいことしなくていいな。やめよう

蔀

差分比較の例

let original = ["a", "b", "c", "d", "e"]
let latest = ["a", "c", "d", "e", "f"]

// セットに変換
let originalSet = Set(original)
let latestSet = Set(latest)

// 追加された要素(originalにはなく、latestにある要素)
let added = latestSet.subtracting(originalSet)     // {"f"}

// 削除された要素(originalにあり、latestにない要素)
let removed = originalSet.subtracting(latestSet)    // {"b"}

// 結果の出力
print("Added:", Array(added))      // ["f"]
print("Removed:", Array(removed))  // ["b"]
蔀

open / closeしてるのあんまり意味ない気がしてきた

private func open(fileUrl: URL) async {
    guard FileManager.default.fileExists(atPath: fileUrl.path) else { return }
    let document = NoteDocument(fileURL: fileUrl)
    let success = await document.open()
    if success {
        noteDocuments.append(document)
    } else {
        // FIXME: - somehow notify failure to user
    }
    await document.close()
}
蔀

あ、消したらデータが空のDocument になってしまった。要りますね

蔀

バックグラウンドでopen/closeやろうとしたら結果がnilになった。UIDocument触る処理はUIKitなので?メインスレッドじゃないとダメそう

private func open(fileUrl: URL) async -> NoteDocument? {
        return await Task.detached(priority: .background) {
            guard FileManager.default.fileExists(atPath: fileUrl.path) else { return nil }
            let document = await NoteDocument(fileURL: fileUrl)
            let success = await document.open()
            await document.close()
            if success {
                return document
            } else {
                return nil
                // FIXME: - somehow notify failure to user
            }
            
        }.value
    }
蔀

A typical document-based app calls open(completionHandler:), close(completionHandler:), and save(to:for:completionHandler:) on the main thread. When the read or save operation kicked off by these methods concludes, the system executes the completion-handler block on the same dispatch queue as the system used to invoke the method, allowing you to complete any tasks contingent on the read or save operation. If the operation isn’t successful, the system passes false to the completion-handler block.

https://developer.apple.com/documentation/uikit/uidocument

蔀

UIKitのviewDidAppearのタイミングでSwiftUIの.onAppearや.taskを呼びたい

蔀

これでいけるかと思ったが、CanvasViewは他のViewからもモーダル遷移しており、そっちは拾えなかった
NotificationCenterかなあ

        .fullScreenCover(isPresented: $showCanvasView, onDismiss: {
            Task {
                await viewModel.incrementalFetch()
            }
        }) {
            NavigationView {
                CanvasView(canvasViewModel: CanvasViewModel())
            }
        }
蔀

サイドバーでNoteList、3つ目をタップすると更新が走らない
それ以外は上手くいっている

蔀

Github Actionsが通らなくて、ガチャガチャする
証明書っぽいエラーに見えたが、シミュレーターバージョンをあるやつにしたら通ったのでこれのせい?

TEST_DEVICE ?= iPad Pro 13-inch (M4)
TEST_OS ?= 18.1
蔀

残件

  • 差分更新
    • サイドバーの切り替え時に .task が呼ばれない問題
  • NoteListのツールバーボタンをまとめる
  • ノート下のタグタップでタグ編集画面に飛ぶ
  • アプリがActiveになったときにCanvasを開きたい
  • 更新がないときは保存をスキップする
  • Filesアプリでフォルダ開きたい
  • 簡単なチュートリアルつけたい
  • Swift Testing入れる
  • Xcode Cloudも有効にしたい
蔀

これで呼ばれるようになった……マジか

Section(header: Text("Folder")) {
    NavigationLink(destination: NoteListParentView(viewModel: inboxNoteViewModel).id("A")) {
        Label("Inbox", systemImage: "tray")
    }
    NavigationLink(destination: NoteListParentView(viewModel: allNoteViewModel).id("B")) {
        Label("All", systemImage: "tray.full")
    }
    NavigationLink(destination: NoteListParentView(viewModel: archivedNoteViewModel).id("C")) {
        Label("Trash", systemImage: "trash")
    }
}
蔀

SwiftLintのエラーが気になる
extension fileがfile_nameで怒られる
色々試したけど、// swiftlint:disable:this file_name でいいっぽい
ただまた別のワーニングが出てしまう

https://github.com/realm/SwiftLint/issues/2277

蔀
Invalid configuration for 'line_length'. Falling back to default.
The `anyobject_protocol` rule is now deprecated and will be completely removed in a future release.
蔀

anyobject_protocolの方は読んで字の如く
上が修正できない

line_length:
  warning: 300
  error: 500
蔀

identifier_nameがコメントアウトされてたのが原因だった……

line_length:
  warning: 300
  error: 500

# identifier_name:
  min_length:
    warning: 1 # `r` `g` `b` などを使いたいため

蔀

NavigationSplitView のドキュメント読んでみると、sideBarの方に遷移ロジック持ってるのがおかしいかも。detailの方に指定するのが正しい

NavigationSplitView {
    sideBarList
} detail: {
    NoteListParentView(viewModel: inboxNoteViewModel)
}
蔀

大幅に書き直したら無事想定通りに .task モディファイヤがコールされるようになった

struct SideBarListView: View {
    @State private var selection: Page? = .inbox
    private enum Page: String, CaseIterable {
        case inbox, all, trash
        case tag
        case setting
    }

    @StateObject private var inboxNoteViewModel = NoteViewModel(targetDirectory: .inbox)
    @StateObject private var allNoteViewModel = NoteViewModel(targetDirectory: .all)
    @StateObject private var archivedNoteViewModel = NoteViewModel(targetDirectory: .archived)

    var body: some View {
        NavigationSplitView {
            sideBarList
        } detail: {
            switch selection {
            case .inbox:
                NoteListParentView(viewModel: inboxNoteViewModel)
            case .all:
                NoteListParentView(viewModel: allNoteViewModel)
            case .trash:
                NoteListParentView(viewModel: archivedNoteViewModel)
            case .tag:
                TagList()
            case .setting:
                SettingView()
            default:
                Text("Unknown page")
            }
        }
    }

    private var sideBarList: some View {
        List(selection: $selection) {
            Section(header: Text("Folders")) {
                NavigationLink(value: Page.inbox) {
                    Label("Inbox", systemImage: "tray")
                }

                NavigationLink(value: Page.all) {
                    Label("All", systemImage: "tray.full")
                }

                NavigationLink(value: Page.trash) {
                    Label("Trash", systemImage: "trash")
                }
            }
            Section(header: Text("Tag")) {
                NavigationLink(value: Page.tag) {
                    Label("Tag List", systemImage: "tag")
                }
            }
            Section(header: Text("Setting")) {
                NavigationLink(value: Page.setting) {
                    Label("Setting", systemImage: "gearshape")
                }
            }
        }
        .navigationTitle("Pieces of Paper")
    }
}

蔀
@State private var selection: Page? = .inbox

ここはコードとしてはオプショナルじゃなくていいんだけど、
Listが非オプショナルのselectionにiOSだと対応していないと言われてしまった

蔀

元のコードは NavigationLink(destination:) で遷移していたのが良くなかった
一見遷移できてしまえたが、本来想定されていた遷移ではなかった

蔀

同一idの UIDocument が更新されているのが不思議だったが、

    private func displayReorderDocuments() {
        displayNoteDocuments = reorderDocuments
    }

この処理で @Published な配列が更新されて、それがトリガーになってる模様

蔀

実機でなぜかNavigationBarが表示されなくなってしまった……
フォースリスタートかけたがダメ

https://support.apple.com/en-ie/guide/ipad/ipad9955c007/ipados

蔀

アプリ削除を試してなかった
アプリ削除でいけた

蔀

本来こう↓なる画面が

↓このように止まってしまう

蔀

iCloudの500ほどあったノートファイルを退避したら解消した
iCloud連携で上手くいかずにハングアップしていたらしいが、詳細はわからず

蔀

これはチュートリアルに入れよう……

蔀

そしてファイル、バックアップ使ったら再現もしそう
根気よくデバッグすればエラーになってるファイルを特定できるかもしれない

蔀
    @Environment(\.scenePhase) private var scenePhase
// …
        .onChange(of: scenePhase) { phase in
            switch phase {
            case .active:
                showCanvasView = true
            default:
                break
            }
        }

Sceneが.activeになったタイミングでCanvas表示するようにしたら、2度初期化が走ってしまう

蔀

SideBarListView は @StateObject で各ViewModel持ってるから、この親コンポーネントが更新されて、
更新が伝播していった結果、Canvasが再作成されているっぽい
CanvasのViewModelは状態変数にしないで渡してるので、再作成される

蔀

@ObservedObject -> @StateObject

struct CanvasView: View {
    @StateObject var canvasViewModel: CanvasViewModel
蔀

iOS 17以上にできたら、@Observable 使ってくのが良さそうだな

蔀

NoteListViewが都合4コンポーネントになっている

NoteListParentView -> NoteScrollView -> NoteListView -> NoteView

NoteViewModel はParentだけ持ってる構成だったんだけど、
よく考えたら子コンポーネントにもViewModel渡しちゃえばいい?試してみる

蔀

NoteViewModel でエラーになったときにアラート出そうとしたけども、Canvasのモーダルと競合して、失敗してしまう……

蔀

追加で気になってるとこ

  • iOSアプリが指受け付けてるせいで操作できない
  • NotificationCenter消せそう
  • Alertうまいことできないか

NotificationCenter消せそう

書き換えたら二重でView更新かかるようになってしまったので見送り

.fullScreenCover(isPresented: $showCanvasView) {
    Task {
        await viewModel.incrementalFetch()
    }
} content: {
    if let path = FilePath.inboxUrl?.appendingPathComponent(FilePath.fileName) {
        NavigationStack {
            CanvasView(canvasViewModel: CanvasViewModel(path: path))
        }
    }
}

iOSアプリが指受け付けてるせいで操作できない

  • drawingPolicy をデフォルトにしたら、最初のタップは受け付けるようになった
蔀

こんな感じでモディファイヤつくって、Canvasにつけてみたけど、ボタンが押せないアラートが出てしまう

// MARK: - Alert Components

extension View {
    func noteListAlert(isPresented: Binding<Bool>, viewModel: NoteViewModel) -> some View {
        modifier(NoteListAlertViewModifier(isPresented: isPresented, viewModel: viewModel))
    }
}

struct NoteListAlertViewModifier: ViewModifier {
    @Binding var isPresented: Bool
    let viewModel: NoteViewModel
    init(isPresented: Binding<Bool>, viewModel: NoteViewModel) {
        self._isPresented = isPresented
        self.viewModel = viewModel
    }

    func body(content: Content) -> some View {
        content.alert("",
                      isPresented: $isPresented,
                      presenting: viewModel.alertType) { type in
        switch type {
        case .iCloudDenied:
            iCloudButton
            localStorageButton
        case .archive:
            archiveActionButton
        case .error:
            Text("OK")
        }
        } message: { type in
            switch type {
            case .iCloudDenied:
                return Text("The app could not access your iCloud Drive. You should change setting")
            case .archive:
                let operationText = viewModel.isTargetDirectoryArchived ? "unarchived" : "archived"
                let countText = viewModel.displayNoteDocuments.count
                return Text("Are you sure you want to \(operationText) \(countText) notes?")
            case let .error(error):
                return Text(error.localizedDescription)
            }
        }
    }

    private var iCloudButton: some View {
        Button {
            guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
            UIApplication.shared.open(url)
        } label: {
            Text("Use iCloud")
        }
    }

    private var localStorageButton: some View {
        Button {
            var userPreference = UserPreference()
            userPreference.enablediCloud = false
            Task {
                await viewModel.incrementalFetch()
            }
        } label: {
            Text("Use device storage")
        }
    }

    private var archiveActionButton: some View {
        Button(role: .destructive) {
            viewModel.isTargetDirectoryArchived
            ? viewModel.allUnarchive()
            : viewModel.allArchive()
        } label: {
            Text(
                viewModel.isTargetDirectoryArchived
                ? "Move all to Inbox"
                : "Move all to Trash"
            )
        }
    }
}

蔀

Xcode Cloudの設定

  • Xcodeのタブから追加
  • ここ(画像は追加済のもの)

  • GitHubのリポジトリとの連携→Xcode→Xcode CloudのWebサイトの流れで設定
  • 初期設定で最初のワークフローをつくることになる
  • 追加でワークフローつくろうとしたら、「共有スキームが見つからなかったため、ワークフローを作成できません」とワーニングが出る
  • Xcode Intergrate > Create Workflow を選んだおかげなのか、時間で解消したのか、ワークフローがつくれるようになっていた
蔀
  • プルリク出したらテスト走るようにした
  • ターゲットブランチがmainで、最初逆に設定してて動かなかった
  • GitHubは最初の連携ができていれば、何もせずに動いた

蔀

ストア提出が上手く動かない
また条件付けも、releaseブランチのプルリクエストのマージタイミングにしたかったが、
Xcode Cloudのデフォルトだとムリそう

蔀

In Xcode 14 and later, iOS, iPadOS, and watchOS apps can auto-generate all icon variations from a single 1024×1024 pixel image. This is the default behavior when you create a new iOS, iPadOS, and watchOS app, or a new icon in the asset catalog. If you have an existing project that provides multiple variants, consider providing a single size when that is all your icon requires. However, if you want to customize your app’s icon variants, such as to show more detail at a larger size, you can provide individual assets for the variations.

蔀

ストア提出のワークフローは一旦手動で

蔀

「App Icons Source」にチェックが要るのかな

蔀

いや違った。macOSのサポートがあったから、macOS用のアイコンが求められている?

蔀

ちなみにエラー内容

Missing required icon file. The bundle does not contain an app icon for iPad of exactly '152x152' pixels, in .png format for iOS versions >= 10.0. To support older operating systems, the icon may be required in the bundle outside of an asset catalog. Make sure the Info.plist file includes appropriate entries referencing the file. See https://developer.apple.com/documentation/bundleresources/information_property_list/user_interface
Missing Info.plist value. A value for the Info.plist key 'CFBundleIconName' is missing in the bundle 'Individual.LikeAPaper'. Apps built with iOS 11 or later SDK must supply app icons in an asset catalog and must also provide a value for this Info.plist key. For more information see http://help.apple.com/xcode/mac/current/#/dev10510b1f7.
Missing required icon file. The bundle does not contain an app icon for iPhone / iPod Touch of exactly '120x120' pixels, in .png format for iOS versions >= 10.0. To support older versions of iOS, the icon may be required in the bundle outside of an asset catalog. Make sure the Info.plist file includes appropriate entries referencing the file. See https://developer.apple.com/documentation/bundleresources/information_property_list/user_interface
蔀

やっぱmacOS設定が邪魔してたみたい
macOS設定ありでアップロードしたかったら、↓この辺が要るみたい

蔀

これができるらしいので入れる


はい、NavigationSplitViewでサイドバーを初期状態で折りたたむことは可能です。これはNavigationSplitViewのcolumnVisibilityプロパティを使用して制御できます。

具体的な実装例をお見せします:

@State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly

NavigationSplitView(columnVisibility: $columnVisibility) {
    // サイドバーの内容
    List {
        // ...
    }
} detail: {
    // 詳細ビューの内容
    Text("詳細ビュー")
}

ここでのポイントは:

  1. @StateプロパティとしてcolumnVisibilityを定義し、初期値を.detailOnlyに設定します
  2. NavigationSplitViewのcolumnVisibilityパラメータにこの状態をバインドします

.detailOnlyを指定することで、アプリ起動時にサイドバーが折りたたまれた状態で表示されます。

他の指定可能な値としては:

  • .automatic: システムのデフォルト動作
  • .all: すべてのカラムを表示
  • .detailOnly: 詳細ビューのみ表示(サイドバーを折りたたむ)

このように設定することで、初期表示時からサイドバーを折りたたんだ状態にすることができます。

このスクラップは2025/02/19にクローズされました