@ViewBuilder content: () -> Content
ってなってるstatic func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)>
buildDo
ってのもあるpublic protocol Identifiable
差分計算みっけextension Optional : Publisher where Wrapped : Publisher {
ええんかwVStack
のtrailing closure で if
文かけるのなぜかわからないのですが説明できる人います? var body: some View { VStack(alignment: .center) { Text("1") if true { Text("2") } Text("3") } }
Text?
になるってコンパイル時にわからなくない。というかTupleの要素数決定できないなと思っていて。それが if
をかいて buildif
が使われる言語機能?って心当たりがあるプロポーザルとかあります? きっとSwiftUIだから特別構文を用意しているとかではないと思っているのですがdiv { var v0_opt: [HTML]? if useChapterTitles { let v0: [HTML] = HTMLBuilder.buildExpression(h1(chapter + "1. Loomings.")) v0_opt = v0 } let v0_result = HTMLBuilder.buildOptional(v0_opt) let v1 = HTMLBuilder.buildExpression(p { "Call me Ishmael. Some years ago" }) let v2 = HTMLBuilder.buildExpression(p { "There is now your insular city" }) return HTMLBuilder.buildBlock(v0_result, v1, v2) }
Jetpack Compose
について調査しました。本記事ではその中でも特徴的な宣言的UI
の構築を行うための文法についてまとめてみます。 edgesIgnoringSafeArea
IgnoringのためのAPIがあるということは、ScrollViewとか関係なく、全てViewは標準だとSafeAreaの中にレンダされるのかなList
でも普通にやったら SafeArea 考慮されてて、 edgesIgnoringSafeArea
で SafeArea 無視されてましたね。fn main() { let x = 5; let x = x + 1; let x = x * 2; println!("The value of x is: {}", x); }
https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing
mut let
のときと let
でシャドーイングのときを区別しないといけなくて、 Swift 的思考だとハマりそう。 (edited)padding
)はインスタンス生成してないとできない。 Text(string) .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .background(Color.white) .cornerRadius(16) .padding(EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10))
GeometryReader
使ってできそう。もっといいやり方ありそうだけど。 struct ContentView : View { var body: some View { VStack { GeometryReader { geometry in VStack { Spacer() .frame( width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.safeAreaInsets.top ) .background(Color.blue) ZStack { Spacer() .frame( width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: 100 ) .background(Color.red) Text("Custom Bar") } } .edgesIgnoringSafeArea(.init([ .top, .leading, .trailing ])) } } } }
(edited)layoutPriority
とかもあるみたいだから、かなりのことはできそう。 https://developer.apple.com/documentation/swiftui/view/3278584-layoutpriorityGeometryReader
胸熱struct ContentView : View { @State var int = 0 var body: some View { let red = int > 0 Text("Hello World") .background(red ? Color.red : Color.blue) } }
↑はビルドエラーになるけど struct ContentView : View { @State var int = 0 var red: Bool { int > 0 } var body: some View { Text("Hello World") .background(red ? Color.red : Color.blue) } }
ならOKですねImage("owl") .resizable() .edgesIgnoringSafeArea([.top, .horizontal])
^ これを Image("owl") .edgesIgnoringSafeArea([.top, .horizontal]) .resizable()
^ このように入れ替えられない、とかけっこう難しいと思うんですよね。@State
なpropertyが中身のオブジェクトのアクセサ持ってるの、これ使ってるんですね https://twitter.com/v_pradeilles/status/1136755695427018752?s=21List
row in #SwiftUI that forwards the tap events to controls in the cell? (i.e. List { VStack { Text(..) Button(action: ..) { Text("Press me") } } }
` Trying to tap the button just selects the celldynamic
がPure Swiftのメソッド・プロパティにも付けられるようになってて、_@dynamicReplacememt
で差し替えられる機構がホットリロードに使われてるようで面白い! https://forums.swift.org/t/how-does-the-hot-reloading-work-in-xcode11/25312
https://forums.swift.org/t/dynamic-method-replacement/16619dynamic
と宣言したモノだけか。Binding<Value>
の .value
-> wrappedValue
に変更されているみたいだけど、Docに反映されていなそう? https://developer.apple.com/documentation/swiftui/binding?changes=latest_beta (edited)SwiftUI.framework/Versions/A/Modules/SwiftUI.swiftmodule/x86_64.swiftinterface
を比較しても、その辺り変わっていない様な。-fprofile-instr-generate
を追加する必要がある」この原理を考察してみました(多分間違ってる)。 考察に納得できてないので、もし知ってることがあれば教えていただきたいです!(この窓で良いのかな…) https://qiita.com/AkkeyLab/items/a698545f2423b9b1dec9$ lipo -extract x86_64 FirebaseCore -output FirebaseCoreX64 $ nm -a FirebaseCoreX64 | grep -i llvm
ダウンロードしたFirebaseCoreには含まれてなさそう。Firebase/Analytics
を pods で入れてます。___llvm_profile_runtime_user
in FirebaseCore
なので、英文的には、見つからなかったのはFirebaseCoreじゃなくて、 FirebaseCoreの中から 参照している llvm_profile_runtime_user
が、見つからなかった、ですね。import SwiftUI struct ContentView: View { var message: String @State var isOn = true var body: some View { VStack { VStack { Toggle(isOn: $isOn) { Text("Switch") .font(.title) .foregroundColor(Color.white) } } .padding() .background(isOn ? Color.purple : Color.orange) Text(message) .font(.largeTitle) .foregroundColor(.blue) } } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView(message: "Hello SwiftUI Playground!") } }
import SwiftUI struct ContentView: View { var body: some View { VStack { // 1. Rectangle() .stroke(Color.blue, lineWidth: 10) .frame(width: 100, height: 100) // 2. Circle() .fill(Color.red) .frame(width: 100, height: 100) // 3. Capsule() .fill(Color.green) .overlay( Capsule() .stroke(Color.black, lineWidth: 10) ) .frame(width: 200, height: 100) // 4. RoundedRectangle(cornerRadius: 20) .fill(Color.yellow) .frame(width: 100, height: 100) } } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() } }
struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() .environment(\.locale, .init(identifier: "ja")) } }
とすると手元ではOKだけどサーバーでは変わらない。 日本語にフォールバックされる要件が相変わらずよくわからない。.environment(\.locale, .init(identifier: "ja"))
関係なかった。PreviewProvider.previews
をMirrorを使ってリフレクションで構造を読み取ります。 modifierの指定を集めるのと、Groupを探してGroupがあったらXcodeの挙動と一致するように分割します。some View
の方がわからないと渡せないので上記のリフレクションで構造を読む際に実際の型名を書き込んだソースコードを生成して、再度ビルドして実行します。 2回目の実行で画像が出力されます。Text("\(Date())")
みたいなコードがあったら文字列としては一致してるけど結果は変わっちゃうとか。import SwiftUI extension View { func when<NewView: View>(_ cond: Bool, @ViewBuilder then apply: (Self) -> NewView) -> some View { Group { if cond { apply(self) } else { self } } } } struct MyView: View { var body: some View { Text("a") // .when(true) { // $0 // } // .when(Bool.random()) { // $0.padding(10).background(Color.yellow) // } // .when(Bool.random()) { // $0.padding(10).background(Color.green) // } .when(true) { view in VStack { Text("begin") view Text("end") } } } } struct MyView_Preview: PreviewProvider { static var previews: some View { MyView() } }
import SwiftUI extension View { func when<NewView: View>(_ cond: Bool, @ViewBuilder then apply: (Self) -> NewView) -> some View { Group { if cond { apply(self) } else { self } } } } struct MyView: View { var body: some View { Text("a") .when(true) { $0 } .when(true) { view in VStack { Text("begin") view Text("end") } } } } struct MyView_Preview: PreviewProvider { static var previews: some View { MyView() } }
22:17: error: cannot convert value of type 'VStack<Content>' to closure result type '_' VStack { ^~~~~~~~
Combine.swiftmodule/x86_64.swiftinterface
のdiffを見ると、いくつかのextensionに@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
が追加されてて、 +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) extension Subject where Self.Output == Swift.Void { public func send() }
ってのも含まれてる。 (edited)nm -mg
でも (undefined) weak external (extension in Combine):Combine.Subject< where A.Output == ()>.send() -> () (from Combine)
になる。swiftinterface
内で@available
が付いていないのを全部チェックしないといけないのですね。@available
よりextensionの@available
が緩かったらコンパイラでエラーにして欲しいな。edgesIgnoringSageArea
にすると(そもそも Safe Area がなくなるので)スクロールしてない状態でも最初から Safe Area の外にはみ出てしまうという状況です。 SwiftUI でうまくやるにはどうすれば良いでしょうか?View
の再利用とかめちゃくちゃやりやすいですしねぇ。いいところはいいんですが・・・。今回は、 Table View ( List
)のセルの操作が結構入りそうな箇所があり、そこを差分計算でやってもらった方が楽そうだなと。そこだけ SwiftUI 埋め込みする方針で検討してみます。UITableViewDiffableDataSource
とかできてたんですね。把握できてませんでした。部分的にでも SwiftUI 使ってみたい気持ちもありますが、これも検討してみます。UIViewController
ベースにして、使えそうなところは UIHostingViewConroller
に入れて使う形を試してたら、これまで GeometryReader
の中にあった NavigationView
を UINavigationController
として外に出すことになって、そうすると geometry が変化しまくって GeometryReader
以下の再描画が走りまくることに・・・。UIHostingViewController
使わずに UIView
ベースにするとなんか解決しそうですね...!import Combine class Foo: ObservableObject { @Published var a: Int = 0 @Published var b: String = "" static let shared: Foo = .init() }
import SwiftUI struct ContentView: View { @ObservedObject var foo: Foo = .shared let cancellable = Foo.shared.objectWillChange.sink { _ in print("change") } var body: some View { print("body") return VStack { Text("a: \(foo.a)") Button("+") { Foo.shared.a += 1 } Text("b: \(foo.b)") Button("+") { Foo.shared.b += "+" } Button("++") { Foo.shared.a += 1 Foo.shared.b += "-" } } } }
↑のようにして、 ++
ボタンを押したときに Foo
の objectWillChange
には 2 回値が流れる( "change"
は 2 回表示される)けど、 "body"
は 1 回しか表示されない。なんとなく、 @State
とか @ObservedObject
とかは変更が生じる度に body
が実行されるのだと思ってました。当たり前かもしれないけど、ちゃんと setNeedDisplay
みたいな仕組みが備わってるんですね。Binding
(のようなもの)が欲しくなることってないですか?変更を通知して子ビューに反映させたいけど、子ビュー側から変更してほしくないようなケースで。 set
を空にする extension
でも書けば良い? あと、 class Foo: ObservableObject
があったときに、 @ObservedObject var foos: [Foo]
的なことしたくなりませんか? @ObservedArray
とかがあればいいのかな? もしくは、僕が知らないだけで、↑を実現する方法が標準で提供されていたりしますか?軽くググっても見つけられなかったんですが・・・。@Binding
なのを見せないでなにか違うものを見せるのかなあ?import SwiftUI extension Binding { static func readOnly<Value>(_ binding: Binding<Value>) -> Binding<Value> { .init(get: { binding.wrappedValue }, set: { _ in }) } }
ObservableObject
の方で↓みたいに read-only にしたい場合は、 $foo.a
みたいにして Binding
が取れないから不便ですね・・・。 class Foo: ObservableObject { @Published private(set) var a: Int = 0 ... }
@ObservedObject
に projectedValue
以外の read-only のための API を作って、 _foo.readOnly.a
みたいに取得できるようにすればいいのかな。 (edited)import SwiftUI extension ObservedObject { var readOnly: ReadOnlyWrapper { .init(self) } @dynamicMemberLookup struct ReadOnlyWrapper { private let object: ObservedObject<ObjectType> init(_ object: ObservedObject<ObjectType>) { self.object = object } subscript<Subject>(dynamicMember keyPath: KeyPath<ObjectType, Subject>) -> Binding<Subject> { Binding(get: { self.object.wrappedValue[keyPath: keyPath] }, set: { _ in assertionFailure("Read-only") }) } } }
Wrapper
に readOnly
生やして $foo.readOnly.a
みたいな方がいいか。KeyPath
を Wrapper
のための ReferenceWritableKeyPath
に変換できない。 import SwiftUI extension ObservedObject.Wrapper { var readOnly: ReadOnly { .init(self) } @dynamicMemberLookup struct ReadOnly { private let wrapper: ObservedObject<ObjectType>.Wrapper init(_ wrapper: ObservedObject<ObjectType>.Wrapper) { self.wrapper = wrapper } subscript<Subject>(dynamicMember keyPath: KeyPath<ObjectType, Subject>) -> Binding<Subject> { get { wrapper[keyPath: keyPath] } // ⛔ set { assertionFailure("Read-only") } } } }
Wrapper
がラップしてる元のオブジェクトを参照できればいいんだけど・・・。subscript
の get
, set
じゃなくて get
で Binding
の set
をつぶさなきゃいけないか。どっちにしろダメだけど。 (edited)Wrapper
に readOnly
生やして $foo.readOnly.a
は諦めて、少々ブサイク(?)だけど ReadOnlyWrapper
方式で _foo.readOnly.a
にするか。 (edited)$foo.a.readOnly
やんね?たしかにそれならできるかも。a
が read-only のときにそもそも $foo.a
が作れないんだった。.lazy
とのアナロジーで考えるとやっぱ Wrapper
に readonly
付けたい気がするなぁ。大元に付けることで下流の性質が変かする。まあ、 _foo.readOnly
でも同じなのかもしれないけど。$foo.readOnly.a
できた。この方法絶対ダメだけどw import SwiftUI extension ObservedObject.Wrapper { var readOnly: ReadOnly { let object: ObjectType = unsafeBitCast(self, to: ObjectType.self) return ReadOnly(object) } @dynamicMemberLookup struct ReadOnly { private let object: ObjectType init(_ object: ObjectType) { self.object = object } subscript<Subject>(dynamicMember keyPath: KeyPath<ObjectType, Subject>) -> Binding<Subject> { Binding<Subject>( get: { self.object[keyPath: keyPath] }, set: { _ in assertionFailure("Read-only") } ) } } }
Wrapper
はどう考えても元のオブジェクトをラップしてるだけだろうと考えて unsafeBitCast
したら動いたwFoo
のような型に対して @ObservedObject var foo: Foo
を作って、 $foo.readOnly.a
のようにして read-only な片方向のバインディングを作りたいということです。 import Combine class Foo: ObservableObject { @Published private(set) var a: Int = 0 func incrementA() { a += 1 } }
incrementA
が露出してるけど、アップデートのための関数は生成者や internal
にしか暴露されないとかもあり得る。そうでなくても、↑の incrementA
のようにアップデートの方法を限定したいからプロパティが private(set)
になるケースもあって、そのようなケースでもバインディングしたいというニーズも考えられる。NumberDisplay
)は let number: Int
で受け取るから書き込みできなくない?Int
は書き込めない型じゃないかな?Binding<Int>
は書き込めるけど。ContentView
が @ObservedObject
として counter
を保持しているから、counter
が変更されたら ContentView
の body
が走って、NumberDisplay(counter.count)
が作り直されて、NumberDisplay
も更新される。ContentView
が @Published var numbers: [Int]
を持つ ObservableObject
を @ObservedObject
で保持していて、NumberDisplay(numbers[0])
とかで表示していると良くなさそう。numbers
全体を表示してる( ContentView
が numbers.count
個の NumberDisplay
を表示している)なら必要な更新だから問題ないと思う。class Counter: ObservedObject { @Published var count: Int @Published var foo: Foo }
のようにfooも持っていてもこれはclassだから分離されているから良いけど・・・ (edited)class Counter: ObservedObject { @Published var count: Count } struct Count { var value: Int var foo: Foo }
(edited)$counter.count.value
でバインディングしたときに、$counter.count.foo
でバインディングしている側には影響が無いようなシステムを期待しているイメージだった。$counter.count.foo
をバインドしてるView Tree側で、勝手に変更チェックをしてfooが変化していなければ実View(FooView)の更新はしないという事をやるのだと思うけど。 (edited)$counter.count.foo
が書き込みインターフェースを提供しない型になるというのがやりたいことだったけど、 counter.count.foo
($を外す) を使えばいいよねという話で解決したという事か@ObservedObject var counter
で掴んでる根っこのところから、毎回出発するからそれはそうなのか。List { ForEach(text, id: \.self) { user in Text(user) }.onDelete{ offset in self.text.remove(atOffsets: offset) }.onMove{ source, destination in self.text.move(fromOffsets: source, toOffset:destination) } } .onTapGesture { UIApplication.shared.endEditing() }
(edited)extension UIApplication { func endEditing() { sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) } }
(edited)clipsToBounds
や masksToBounds
がデフォルト false
なので cornerRadius
を掛けたとしてもちゃんとはみ出すので、それと同じノリでSwiftUIでもはみだしてくれるやろと雑に突撃して撃沈した感じです (↑のSwiftUIに条件を揃えたpaddingによるはみ出しではないですが) (edited)import SwiftUI struct ContentView: View { @ObservedObject var counter: Counter = .shared var body: some View { print("ContentView.body") return NavigationView { VStack { HStack { Text("\(counter.count)") Button("+") { self.counter.increment() } } NavigationLink(destination: AView(count: counter.count)) { Text("Open A") } } } } } struct AView: View { let count: Int var body: some View { print("AView.body") return VStack { HStack { Text("\(count)") Button("+") { Counter.shared.increment() } } NavigationLink(destination: BView(count: count)) { Text("Open B") } } } } struct BView: View { let count: Int var body: some View { print("BView.body") return HStack { Text("\(count)") Button("+") { Counter.shared.increment() } } } } final class Counter: ObservableObject { @Published private(set) var count: Int = 0 func increment() { count += 1 } static let shared: Counter = .init() }
@ObservedObject
の影響範囲を調べてて、↑で AView
を開いてから + ボタン押しても変更が反映されるのがおどろきだったんですが、さらに BView
を開いて + ボタンを押しても変更は反映されないし、そこから Back して AView
に戻ってから + ボタンを押しても今度は変更が反映されないんですが、挙動がおかしくないですか? (edited)AView
を開いたときに変更が反映されるのがおかしいのか、それとも AView
に戻ってきたときや BView
で変更が反映されないのがおかしいのか。Counter.shared
ってSwiftUIシステムから感知できない形で依存性をもってしまっている事じゃないですかねCounter.shared
ってSwiftUIシステムから感知できない形で依存性をもってしまっている事じゃないですかね Counter.shared
はそれをエミュレートしてるのと変わらない気がするので、納得いかないです
@ObservedObject
として引数で渡すとか、 @EnvironmentObject
だっけ? として渡さないといけないのではimport SwiftUI struct ContentView: View { @ObservedObject var counter: Counter = .shared var body: some View { print("ContentView.body") return NavigationView { VStack { HStack { Text("\(counter.count)") Button("+") { self.counter.increment() } } AView(count: counter.count) } } } } struct AView: View { let count: Int var body: some View { print("AView.body") return VStack { HStack { Text("\(count)") Button("+") { Counter.shared.increment() } } BView(count: count) } } } struct BView: View { let count: Int var body: some View { print("BView.body") return HStack { Text("\(count)") Button("+") { Counter.shared.increment() } } } } final class Counter: ObservableObject { @Published private(set) var count: Int = 0 func increment() { count += 1 } static let shared: Counter = .init() } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
@EnvironmentObject
等に限定されているべきで、一般のグローバル変数は触ってはいけないんじゃないかなあ。@EnvironmentObject
でCounterを参照していたとしても、このパターンだとやっぱり更新されない気がしてきた BViewにとってのcountがletなので。@EnvironmentObject
経由で値を見ているべきで、 ContentView → AView → BView の過程の値の加工パスは前提にしちゃいけないような気がするstruct ContentView: View { var body: some View { print("ContentView.body") return NavigationView { XView() } .environmentObject(Env()) } } struct XView: View { @EnvironmentObject var env: Env var body: some View { NavigationLink(destination: YView(), isActive: $env.isRootNavitaionActive) { Text("To Y") } .navigationBarTitle("X") } } struct YView: View { @EnvironmentObject var env: Env var body: some View { VStack { NavigationLink(destination: ZView()) { Text("To Z") } Button(action: { self.env.isRootNavitaionActive = false }) { Text("Tap this") } } .navigationBarTitle("Y") } } struct ZView: View { @EnvironmentObject var env: Env @State var text: String = "(Before tap)" var body: some View { VStack { Text(text) Button(action: { self.text = "(After tap)" self.env.isRootNavitaionActive = false }) { Text("Tap this") } } .navigationBarTitle("Z") } } final class Env: ObservableObject { @Published var isRootNavitaionActive = false }
BView
でボタン押すと count は増えないけど、 ContentView.body
は発火してるんですよね。print
で確認できる。AView
にcount
プロパティを作らなかったら、変更が反映されない。これは理解できる。count
プロパティがないと View
の差分がないので AView
の再描画が走らないのかと。NavigationLink
の場合、クロージャではなく引数に直接 AView(count: counter.count)
が渡されてるから、そこでつながってるのも理解できる。ContentView
が再描画されるんだったら(これは iOS 13 & BView
からでも確認済み)、 AView
が新しい count
で作られて、差分が発生するから再描画されてもおかしくない。struct ContentView2: View { @ObservedObject private var counter: Counter = .shared var body: some View { print("ContentView2.body") return VStack { HStack { Text("\(counter.count)") Button("+") { self.counter.increment() } } AView() .padding() .border(Color.gray, width: 1) .padding() } } } private struct AView: View { var body: some View { print("AView.body") return VStack { HStack { Text("\(Counter.shared.count)") Button("+") { Counter.shared.increment() } } BView() .padding() .border(Color.gray, width: 1) .padding() } } } private struct BView: View { var body: some View { print("BView.body") return HStack { Text("\(Counter.shared.count)") Button("+") { Counter.shared.increment() } } } } private final class Counter: ObservableObject { @Published private(set) var count: Int = 0 func increment() { count += 1 } static let shared: Counter = .init() }
↑これだと同一画面だけど AView
や BView
は更新されない( iOS 13 )。おもしろい。 (edited)AView
に diff がないから body
の発火まで行かないのかと。 (edited)AView
と BView
を展開して ContentView2
に埋め込んだ場合は更新されますよね?body
が呼ばれてないんですが、 body
が呼ばれるかどうかの判断に、ツリー全体じゃなくて AView
としての比較が入ってるってことですよね。Equatable
でないプロパティ持ってることもありますよね・・・。Equatable
の ==
は関係なさそう。import SwiftUI struct ContentView2: View { @ObservedObject private var counter: Counter = .shared var body: some View { print("ContentView2.body") return VStack { HStack { Text("\(counter.count)") Button("+") { self.counter.increment() } } AView(value: .nan) .padding() .border(Color.gray, width: 1) .padding() } } } private struct AView: View { let value: Double var body: some View { print("AView.body") return VStack { HStack { Text("\(Counter.shared.count), \(value)") Button("+") { Counter.shared.increment() } } BView(value: value) .padding() .border(Color.gray, width: 1) .padding() } } } private struct BView: View { let value: Double var body: some View { print("BView.body") return HStack { Text("\(Counter.shared.count), \(value)") Button("+") { Counter.shared.increment() } } } } private final class Counter: ObservableObject { @Published private(set) var count: Int = 0 func increment() { count += 1 } static let shared: Counter = .init() }
AView
と BView
は value
を持っていて、そこに .nan
を渡してるから==
で比較すると常に false
になるんだけど再描画されない。.nan
で常にfalseにする技だwvalue
に Double(counter.count)
を渡したら再描画される。Equatable
なものを比較してたらという仮説に対してですね。AView
と BView
に Equatable
を付けてみても結果は変わりませんでした。AView(value: Bool.random() ? 1 : 2)
import SwiftUI struct ContentView2: View { @ObservedObject private var counter: Counter = .shared var body: some View { print("ContentView2.body") return VStack { HStack { Text("\(counter.count)") Button("+") { self.counter.increment() } } AView(value: Integer(42)) .padding() .border(Color.gray, width: 1) .padding() } } } private struct AView<Value: CustomStringConvertible>: View { let value: Value var body: some View { print("AView.body") return VStack { HStack { Text("\(Counter.shared.count), \(value.description)") Button("+") { Counter.shared.increment() } } BView(value: value) .padding() .border(Color.gray, width: 1) .padding() } } } private struct BView<Value: CustomStringConvertible>: View { let value: Value var body: some View { print("BView.body") return HStack { Text("\(Counter.shared.count), \(value.description)") Button("+") { Counter.shared.increment() } } } } private final class Counter: ObservableObject { @Published private(set) var count: Int = 0 func increment() { count += 1 } static let shared: Counter = .init() } final class Integer: Hashable, CustomStringConvertible { let value: Int init(_ value: Int) { self.value = value } static func == (lhs: Integer, rhs: Integer) -> Bool { lhs.value == rhs.value } func hash(into hasher: inout Hasher) { value.hash(into: &hasher) } var description: String { value.description } }
Equatable
を見てるかも?final class Integer: Hashable, CustomStringConvertible { let value: Int init(_ value: Int) { self.value = value } static func == (lhs: Integer, rhs: Integer) -> Bool { true } func hash(into hasher: inout Hasher) { 42.hash(into: &hasher) } var description: String { value.description } }
に変えて、 AView(value: Integer(Bool.random() ? 1 : 2))
にしたら更新されなくなった。Integer
変更前は AView(value: Integer(Bool.random() ? 1 : 2))
で更新された。Equatable
を見てる??import SwiftUI struct ContentView2: View { @ObservedObject private var counter: Counter = .shared var body: some View { print("ContentView2.body") return VStack { HStack { Text("\(counter.count)") Button("+") { self.counter.increment() } } AView(value: Box<Double>(.nan)) .padding() .border(Color.gray, width: 1) .padding() } } } private struct AView<Value: CustomStringConvertible>: View { let value: Value var body: some View { print("AView.body") return VStack { HStack { Text("\(Counter.shared.count), \(value.description)") Button("+") { Counter.shared.increment() } } BView(value: value) .padding() .border(Color.gray, width: 1) .padding() } } } private struct BView<Value: CustomStringConvertible>: View { let value: Value var body: some View { print("BView.body") return HStack { Text("\(Counter.shared.count), \(value.description)") Button("+") { Counter.shared.increment() } } } } private final class Counter: ObservableObject { @Published private(set) var count: Int = 0 func increment() { count += 1 } static let shared: Counter = .init() } final class Box<Value>: Equatable, CustomStringConvertible where Value: Equatable, Value: CustomStringConvertible { let value: Value init(_ value: Value) { self.value = value } static func == (lhs: Box<Value>, rhs: Box<Value>) -> Bool { lhs.value == rhs.value } var description: String { value.description } }
↑これだと常に更新される。やっぱ ==
が見られてるっぽい。AView(value: Box<Double>(.nan))
を AView(value: Box<Double>(42))
に変更すると更新されなくなる。Equatable
な参照型は ==
が見られるのが確かみたい。Integer
を持つようにしても同じなのかなあとちょっと思っただけです。AView(value: Box<Double>(.nan))
を AView(value: Double.nan)
に変えるだけで参照型を値型にして試せるので。Double.nan
のときは変更が反映されません。Equatable
でないクラス Container
↓を作って final class Container<Value: CustomStringConvertible>: CustomStringConvertible { let value: Value init(_ value: Value) { self.value = value } var description: String { value.description } }
AView(value: Container<Double>(42))
でも AView(value: Container<Double>(.nan))
でも常に更新が反映されました。Equatable
な参照型→ ==
Equatable
でない参照型→アドレス比較(アドレスのビット比較)Equatable
な参照型だけ例外的と言えるかも。AG::LayoutDescriptor::compare_heap_objects(void const*, void const*, unsigned int, bool) AG::LayoutDescriptor::compare_indirect(AG::ValueLayout&, AG::swift::metadata const*, AG::swift::metadata const*, unsigned int, unsigned char const*, unsigned char const*) AG::LayoutDescriptor::compare_existential_values(AG::swift::existential_type_metadata const*, unsigned char const*, unsigned char const*, unsigned int)
#3 0x00007fff4a9b636e in AG::LayoutDescriptor::compare(unsigned char const*, unsigned char const*, unsigned char const*, unsigned long, unsigned int) ()
からジャンプしうるシンボル3種類↑Box<Double>
をジェネリックでない Number
↓に変えても同じだった。 final class Number: Equatable, CustomStringConvertible { let value: Double init(_ value: Double) { self.value = value } static func == (lhs: Number, rhs: Number) -> Bool { print("Box.==") return lhs.value == rhs.value } var description: String { value.description } }
@Environment
や @EnvironmentObject
以外に、コンテクスト的に渡して使えるけど、イミュータブルなものを扱う方法って何かありますか?ダミーのイミュータブルな ObservableObject
を作れば良い?@EnvironmentObject
って型でマッチされるの?? import SwiftUI struct ContentView: View { var body: some View { SubView() .environmentObject(Foo(2)) .environmentObject(Bar(3)) .environmentObject(Bar(5)) } } struct SubView: View { @EnvironmentObject var foo: Foo @EnvironmentObject var bar1: Bar @EnvironmentObject var bar2: Bar var body: some View { VStack { Text("\(foo.value)") Text("\(bar1.value)") Text("\(bar2.value)") } } } final class Foo: ObservableObject { @Published var value: Int init(_ value: Int) { self.value = value } } final class Bar: ObservableObject { @Published var value: Int init(_ value: Int) { self.value = value } }
↑の実行結果は 2 3 3
(edited)import SwiftUI struct ContentView: View { var body: some View { SubView() .environmentObject(Foo(2)) } } struct SubView: View { @EnvironmentObject var foo: Foo var body: some View { VStack { SubsubView() .environmentObject(Foo(5)) Text("\(foo.value)") } } } struct SubsubView: View { @EnvironmentObject var foo: Foo var body: some View { VStack { Text("\(foo.value)") } } } final class Foo: ObservableObject { @Published var value: Int init(_ value: Int) { self.value = value } }
5 2
期待通り@EnvironmentObject
にできるかは一度おいておいて、) NSNumber
二つは紐付けられないけど Int
と Double
ならできるとか、型でマッチするのって違和感があるというか。@EnvironmentObject
は紐付け忘れたらクラッシュだから、そもそも安全なわけじゃないからなぁ。ミスする箇所が減るとは言っても。
@EnvironmentObject
を使う箇所を通過しないとクラッシュしないことないかな?たとえば、ボタン押したクロージャの中から参照してるとか。@ObservedObject
との比較で言えば、 @EnvironmentObject
って @ObservedObject
を都度渡してチェーンするのの簡略版だと思うんだけど、 @ObservedObject
なら同じ型のものを複数持てるけど、 @EnvironmentObject
だとそれができないというところに、「簡略版」以上の不必要な意味を持ってしまっているというか・・・。@EnvironmentObject
で管理しようとすると破綻しない?class Style: EnvironmentObject { var textColor: Color var fontSize: Int }
(edited)@EnvironmentObject
を前提とするとそういう使い方になるんだけど、@EnvironmentObject
が型でマッチする設計になっていることに違和感を感じているという話です。EnvironmentKey
の便利版みたいな位置づけだといいのかなぁ・・・。@EnvironmentObject
で管理しようとすると破綻しない? @Environment
でやることを想定してるんじゃないかなあ。
@EnvironmentObject
を使う場合は、型でマッチすることを割り切って使う場合って感じでしょうか・・・。@Environment
だとデフォルト値があるので、付与し忘れてクラッシュがないという違いはありそうですね。List
と NavigationLink
使ってるときに、子側をトリガーとして変更が発生したときに、親の List
が再描画されると子が pop されて親 View
まで戻される現象が発生した・・・。これは許容し難い。どういうケースで起こるのか最小構成作って試してみないと・・・。NavigationLink
の発行元が消滅したから?List
で表示してて、アイテムを選択してアイテムページに遷移して、そこで閲覧ボタンを押すと履歴が書き換わって pop される・・・。NavigationLink
の同一性みたいなのがあるのかな・・・。PassthroughSubject
経由で更新通知を発火してるから、入れ替えてる途中に消えてる瞬間もないはずなんだよなぁ。body
が一度しか発火されてないことを確認してみるか。NavigationLink
使わずに UINavigationController
経由で push
させれば回避できた。NavigationView
にせずに UINavigationController
にしておいて、カスタム @Environment
に渡してある。NavigationLink
鬼門ですね・・・ (edited)NavigationLink
の isActive:
のBindingをとる版とか、 tag:
と selection:
版とかだとどれが開いているかっていうStateがこっちが与えたBindingに保持されますが、ないやつはどこが覚えてるんでしょうね。isActive
に用語を合わせるなら、どれがアクティブか) (edited)NavigationLink
ってそれ自体にはID渡さないですよね。body
はたしかに走ってるんだけど、 pop されない。Identifiable
の id
でも見ないと判定できなくないですか?id
同じ NavigationLink
があれば pop しないでほしいけど・・・。@State
で持たせた変数って、そのViewが List
の中に入ってて、そのリスト上の位置が変わったときって、状態は保持されてるんですかね。@State
で状態を持っていて、その親のViewの関係ないところに更新が走ったとしても、 @State
で持っている状態は保持されないと困りますよね。でも、親のViewの body
は呼ばれるはずだから、子Viewだってイニシャライザが走って別物になっているはず。でも、 @State
はどこかで同一性を判定して覚えてくれてるはず。List
じゃないケースだと、イニシャライザが走っても @State
が保持されてるのは確認したことがあります。import SwiftUI import Foundation import Combine struct ContentView: View { @ObservedObject var history: History = .shared let dateFormatter: DateFormatter = { let value = DateFormatter() value.dateFormat = "yyyy-MM-dd HH:mm:ss" return value }() let items: [Item] = [ Item(id: "A"), Item(id: "B"), Item(id: "C"), ] var body: some View { NavigationView { VStack { VStack { ForEach(items) { item in NavigationLink(destination: ItemView(item: item)) { Text("Open \(item.id)") .padding(4) } } } .padding() Text("履歴") .font(.headline) .padding() Divider() List(history.records.reversed()) { record in NavigationLink(destination: ItemView( item: self.items.first(where: { $0.id == record.id })! )) { VStack(alignment: .leading) { Text("\(record.id)") .font(.headline) Text("\(self.dateFormatter.string(from: record.lastPlayed))") .font(.subheadline) .foregroundColor(.secondary) } } } } } } } struct ItemView: View { let item: Item var body: some View { VStack { Text("\(item.id)") Button("Play") { History.shared.record(for: self.item.id) } } } }
struct Item: Identifiable { let id: String } final class History: ObservableObject { private(set) var records: [Record] = [] private let subject: PassthroughSubject<Void, Never> = .init() var objectWillChange: AnyPublisher<Void, Never> { subject.eraseToAnyPublisher() } func record(for id: Item.ID) { records.removeAll(where: { $0.id == id }) records.append(Record(id: id, lastPlayed: Date())) subject.send(()) } static let shared: History = .init() struct Record: Identifiable { let id: Item.ID let lastPlayed: Date } }
struct ContentView: View { var body: some View { NavigationView { FirstView() } .environmentObject(Env(items: [ Item(id: "C"), Item(id: "B"), Item(id: "A"), ])) } } struct FirstView: View { @EnvironmentObject var env: Env var body: some View { List(env.items) { item in NavigationLink(destination: SecondView(id: item.id)) { Text("\(item.id)") } } } } struct SecondView: View { @EnvironmentObject var env: Env let id: String init(id: String) { self.id = id } var body: some View { Button("\(id)") { self.env.items = [ Item(id: "A"), Item(id: "C"), Item(id: "B"), ] } } } struct Item: Identifiable { let id: String } final class Env: ObservableObject { @Published var items: [Item] init(items: [Item]) { self.items = items } }
↑だいぶ単純化しましたが再現しますね。(Aの行をタップして、遷移先でAをタップ)struct ContentView: View { var body: some View { NavigationView { FirstView() } .environmentObject(Env(items: [ Item(id: "C"), Item(id: "B"), Item(id: "A"), ])) } } struct FirstView: View { @EnvironmentObject var env: Env var body: some View { VStack { Button("move") { self.env.items = [ Item(id: "A"), Item(id: "C"), Item(id: "B"), ] } List(env.items) { item in ItemView(id: item.id) } } } } struct ItemView: View { let id: String @State var flag: Bool = false var body: some View { HStack { Text("\(id)") Button(flag ? "true" : "false") { self.flag.toggle() } } } } struct Item: Identifiable { let id: String } final class Env: ObservableObject { @Published var items: [Item] init(items: [Item]) { self.items = items } }
↑ぼくの疑問だった @State
は並び替えてもちゃんと保たれている様子。(falseの部分をタップするとtrueとfalseがトグルするので適当に変更してからmoveを押して並び替えてもtrue, falseは保たれて並び変わる)import SwiftUI struct ContentView: View { var body: some View { NavigationView { FirstView() } .environmentObject(Env(items: [ Item(id: "C"), Item(id: "B"), Item(id: "A"), ])) } } struct FirstView: View { @EnvironmentObject var env: Env var body: some View { VStack { Button("move") { self.env.items = [ Item(id: "A"), Item(id: "C"), Item(id: "B"), ] } List(env.items) { item in ItemView(id: item.id) } } } } struct ItemView: View { let id: String @State var flag: Bool = false var body: some View { NavigationLink(destination: SecondView(id: id)) { HStack { Text("\(id)") Text(flag ? "true" : "false") .onTapGesture { self.flag.toggle() } } } } } struct SecondView: View { @EnvironmentObject var env: Env let id: String init(id: String) { self.id = id } var body: some View { Button("\(id)") { self.env.items = [ Item(id: "A"), Item(id: "C"), Item(id: "B"), ] } } } struct Item: Identifiable { let id: String }
NavigationLink
で遷移して強制的にpopされるパターンだと @State
で覚えてるやつもクリアされる。どうも完全にリセットされてるっぽいですね。 (edited)@State
で覚えてるようなものがリセットされるからpopされるんだとは思いますが。FirstView
の List
のところを、 List { ForEach(env.items) { item in ItemView(id: item.id) } }
とすると、いったんpopされるけどすぐにpushされてtrue、falseの状態も保たれてます。 List
と ForEach
の違いはなに? List
を消すと(つまり ForEach
だけにすると)、TableView表示はされなくなるんですが、AとかBとかの部分を押すと画面遷移はします。ただ、そうすると強制popは起こらなくなります。List
と NavigationLink
の組み合わせでこの現象は起こってるぽいですね。List
は UITableView
と同じで lazily に動作して見えてない部分は作らない機構があって、見えてないとプログラムから遷移させられないという問題が書かれているんですが、今回のもそのlazilyなための仕組みが関係あるかもしれませんね。List
が表示されていない間は流れをせき止める ObservableObject
でラップすることで、遷移先からの更新の反映を遅らせることができました。 final class ObservableDam<WrappedValue: ObservableObject>: ObservableObject { typealias ObjectWillChangePublisher = AnyPublisher<WrappedValue.ObjectWillChangePublisher.Output, Never> let wrappedValue: WrappedValue private var cancellable: AnyCancellable? = nil var isActive: Bool = true { didSet { if isActive { let outputs = self.outputs self.outputs = [] for output in outputs { subject.send(output) } } } } private var outputs: [ObjectWillChangePublisher.Output] = [] private let subject: PassthroughSubject<ObjectWillChangePublisher.Output, Never> = .init() var objectWillChange: ObjectWillChangePublisher { subject.eraseToAnyPublisher() } init(_ wrappedValue: WrappedValue) { self.wrappedValue = wrappedValue cancellable = wrappedValue.objectWillChange.sink { [weak self] output in guard let self = self else { return } if self.isActive { self.subject.send(output) } else { self.outputs.append(output) } } } }
struct ContentView: View { @ObservedObject var history: ObservableDam<History> = .init(.shared)
↑こうして } .onAppear { self.history.isActive = true } .onDisappear { self.history.isActive = false }
↑こうする。List
は内部的には UITableView
を使って実現されていて、裏で、 UITableView
の reloadData()
なり beginUpdates()
〜 endUpdates()
をしてるんじゃないかと推測。NavigationLink
も存在しなくなって戻るのではないかと思いました。LazyVStack
ではどうなるのかがちょっと気にはなってます。前画面でスクロール範囲から見えなくなったらpopされたりすることがあるのだと嫌だなあと。気になってるだけで試してませんが。NavigationLink
使わずに navigationController
を取り出して push
したら回避はできるんですが、今度は List
のセルに disclosure indicator を出すのが NavigationLink
の有無と連動してたり、セル選択のときの UI の挙動(セルの背景がグレーになるとか)が異なってたりでちょっと微妙です・・・。ObservableObject
をかまそうかと思ってますが、 iOS 14 だと N 階層でも伝わるので、事故を回避するためには List
以下で変更される可能性のあるもの全部に付けないといけなくてかなり面倒です・・・。static var shared: History
だからトリッキーなことに見えますけど、普通に Core Data で @FetchRequest
使ってる場合とか同じじゃないかと思うんですよねぇ・・・。GeometryReader
を使う方が親のサイズに合わせることができていいのかなあと思いました。たぶん、そうしたからといって質問の件はぜんぜん解決しないとは思いますが