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

NavigationSplitView
使えばデフォルトでサイドバー非表示にできるかなと思って、↓設定したけど、横向きだと非表示にならなかった

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

SideBarList
まで対応

ScrollViewを噛ませるとどうしても左寄せになってしまう。
スペースが確保されないっぽい
HStack {
VStack {
// …
}
Divider()
VStack() {
ScrollView(.horizontal) {
Text("long text")
}
}
.padding()
}

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

@Binding変数のイニシャライザ

CanvasViewのSwiftUI化、既存のやつがイマイチすぎるので、これを参考にしてつくりなおす

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

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

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

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

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

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

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

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.

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

これでいけるかと思ったが、CanvasViewは他のViewからもモーダル遷移しており、そっちは拾えなかった
NotificationCenterかなあ
.fullScreenCover(isPresented: $showCanvasView, onDismiss: {
Task {
await viewModel.incrementalFetch()
}
}) {
NavigationView {
CanvasView(canvasViewModel: CanvasViewModel())
}
}

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

Github Actions、Xcode 16削除されたのか……

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
でいいっぽい
ただまた別のワーニングが出てしまう

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が表示されなくなってしまった……
フォースリスタートかけたがダメ

@Environment(\.scenePhase) private var scenePhase
// …
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
showCanvasView = true
default:
break
}
}
Sceneが.activeになったタイミングでCanvas表示するようにしたら、2度初期化が走ってしまう

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
をデフォルトにしたら、最初のタップは受け付けるようになった

Swift Testing

こんな感じでモディファイヤつくって、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のデフォルトだとムリそう

Missing app icon. Include a large app icon as a 1024 by 1024 pixel PNG for the 'Any Appearance' image well in the asset catalog of apps built for iOS or iPadOS. Without this icon, apps can't be submitted for review. For details, visit: https://developer.apple.com/documentation/xcode/configuring-your-app-icon.

1024 x 1024の画像ひとつでiOS/iPadアプリはいける
今も1024 x 1024設定してるけど、それだとダメなのか

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設定ありでアップロードしたかったら、↓この辺が要るみたい

ストア提出DONE

これができるらしいので入れる
はい、NavigationSplitViewでサイドバーを初期状態で折りたたむことは可能です。これはNavigationSplitViewのcolumnVisibilityプロパティを使用して制御できます。
具体的な実装例をお見せします:
@State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly
NavigationSplitView(columnVisibility: $columnVisibility) {
// サイドバーの内容
List {
// ...
}
} detail: {
// 詳細ビューの内容
Text("詳細ビュー")
}
ここでのポイントは:
-
@State
プロパティとしてcolumnVisibility
を定義し、初期値を.detailOnly
に設定します - NavigationSplitViewの
columnVisibility
パラメータにこの状態をバインドします
.detailOnly
を指定することで、アプリ起動時にサイドバーが折りたたまれた状態で表示されます。
他の指定可能な値としては:
-
.automatic
: システムのデフォルト動作 -
.all
: すべてのカラムを表示 -
.detailOnly
: 詳細ビューのみ表示(サイドバーを折りたたむ)
このように設定することで、初期表示時からサイドバーを折りたたんだ状態にすることができます。

リリース完了