【SwiftUI】.onSubmit を使うと View で保持している Observable なクラスが解放されない、、?

はじめに

こちらの Swift Forums で Post している内容と同じです。まだ解決していないですが、現状をまとめておきます。

forums.swift.org

筆者環境

説明用コード

事象の説明のために下記のような簡易的なアプリケーションを考えます。
TextField に .onSubmit(of:_:) modifier を使っています。

import SwiftUI
import Observation

struct FirstView: View {
  @State private var isPresented: Bool = false
  
  var body: some View {
    Button("show") {
      isPresented = true
    }
    .sheet(isPresented: $isPresented) {
      SecondView()
    }
  }
}

struct SecondView: View {
  @State private var model = Model()
  @FocusState private var focusedField: Field?
  
  private enum Field: Hashable {
    case field1, field2
  }
  
  var body: some View {
    Form {
      TextField("field 1", text: $model.field1)
        .focused($focusedField, equals: .field1)
        .onSubmit {
          focusedField = .field2
        }
      TextField("field 2", text: $model.field2)
        .focused($focusedField, equals: .field2)
    }
  }
}

@Observable final class Model {
  var field1: String = ""
  var field2: String = ""
  
  deinit {
    print("DEINIT")
  }
}
  • FirstView から SecondView を sheet で表示
  • SecondView では TextField が2つあり、1つ目の TextField の入力完了で 2つ目の TextField にフォーカスが移動する
    • .onSubmit に書いた処理によるもの

さて、ここで sheet を閉じて SecondView のライフタイムが終了した時に、 SecondView が保持している Model クラスが破棄されることを期待します。
今回の実装だとデバッグコンソールに DEINIT が出力されるはずです。

が、実際には出力されず、メモリグラフを見ても Model クラスが生存していることが分かります。

結論、 .onSubmit が何か怪しいようで、 .onSubmit のクロージャを空にすると事象が再現しなくなります。

回避策

.onSubmit modifier を使わず、 TextField の deprecated な init(_:text:onCommit:) を使う。

onCommit で onSubmit 相当の処理を行うことで、一旦は回避できるようです。
(deprecated なのでできれば使いたくないですが、、)

ref: https://developer.apple.com/forums/thread/780579

原因

.onSubmit が何か怪しくはあるのですが、原因は分かっていません。。
前述の通り、 Swift Forums にポストしていますが、本記事の執筆時点で特に反応はなく、理由は分かっていません。。
もし分かる方がいらっしゃったらぜひ教えてください 🙏

その他

最小限にしたコード

以下、説明用のコードよりも最小限にした再現コードを置いておきます。
Model クラスはプロパティすら持っておらず、保持しているだけです。

import SwiftUI
import Observation

struct FirstView: View {
  @State private var isPresented: Bool = false
  
  var body: some View {
    Button("show") {
      isPresented = true
    }
    .sheet(isPresented: $isPresented) {
      SecondView()
    }
  }
}

struct SecondView: View {
  private var model = Model()
  private let flag: Bool = true
  
  var body: some View {
    TextField("field", text: .constant(""))
      .onSubmit { _ = flag }
  }
}

@Observable final class Model {
  deinit {
    print("DEINIT")
  }
}

その他の挙動

  • TextField へのフォーカスを当てずに sheet を閉じると解放される
  • 解放されなかった場合でも、再度 SecondView を開いて TextField にフォーカスを当てた段階で前回解放されなかった Model が解放される
    • 解放されるタイミングこそ不可解ではあるが、解放されないインスタンスが増え続けるという感じではなさそう
  • Model が Observation framework の @Observable でなく Combine の ObservableObject を利用した場合でも同様の事象が発生する

筆者の推測

メモリグラフを見た感じ、 .onSubmit は内部的に Environment の仕組みを使っているように見えました(多分)。そのため SwiftUI が管轄している Environment の値をストアしている箇所でキャプチャしているとかある、、?

おわりに

なかなかよく分からない挙動ですが、 TextField で onSubmit を使うのは割とあるユースケースな気がするので、皆さんどうされているんでしょうか、、? 筆者が何か使い方や解釈を間違えている可能性もあるので、情報ある方教えていただきたいです 🙏