perf(keyboard): track KeyboardState per property with @Observable
- KeyboardState moves from ObservableObject/@Published to @Observable, so a view re-renders only for the properties it reads. Previously unpublished state and injected action hooks are @ObservationIgnored. - KeyboardViewController replaces the $phase/$surface Combine sinks with Observations loops tied to the same cancellables. - AIKeyboardView reads the 200 ms mic level and live ASR partials in small child views (AssistantWaveform, LiveTranscriptReader), so those ticks no longer rebuild the whole assistant surface. - Tests observe changes with withObservationTracking instead of Combine publishers.
This commit is contained in:
@@ -778,28 +778,38 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func installSurfaceObservers() {
|
||||
state.$phase
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if self.state.locksTypingSurface, self.state.surface == .typing {
|
||||
self.applySurface(.voice)
|
||||
}
|
||||
let state = state
|
||||
// `Phase` is not Sendable; reading it only registers the dependency.
|
||||
observe(Observations { _ = state.phase }) { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.state.locksTypingSurface, self.state.surface == .typing {
|
||||
self.applySurface(.voice)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
state.$surface
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] surface in
|
||||
guard let self else { return }
|
||||
if surface == .typing {
|
||||
self.activateTypingPresentationIfNeeded()
|
||||
} else {
|
||||
self.deactivateTypingSurface()
|
||||
}
|
||||
self.refreshKeyboardHeight()
|
||||
observe(Observations { state.surface }) { [weak self] surface in
|
||||
guard let self else { return }
|
||||
if surface == .typing {
|
||||
self.activateTypingPresentationIfNeeded()
|
||||
} else {
|
||||
self.deactivateTypingSurface()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
self.refreshKeyboardHeight()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `handler` for the current value and every later change. The loop
|
||||
/// is cancelled together with `cancellables` when the controller goes away.
|
||||
private func observe<Element: Sendable>(
|
||||
_ values: Observations<Element, Never>,
|
||||
handler: @escaping @MainActor (Element) -> Void
|
||||
) {
|
||||
let task = Task { @MainActor in
|
||||
for await value in values {
|
||||
handler(value)
|
||||
}
|
||||
}
|
||||
cancellables.insert(AnyCancellable { task.cancel() })
|
||||
}
|
||||
|
||||
/// This App Group property is supplied by the parallel settings module.
|
||||
|
||||
@@ -10,7 +10,7 @@ struct KeyboardSurfaceRoot: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Namespace private var keyboardTabSelectionNamespace
|
||||
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
|
||||
@@ -38,7 +38,7 @@ enum TypingLayoutMetrics {
|
||||
|
||||
struct TypingRootView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
|
||||
@@ -64,7 +64,7 @@ struct AIKeyboardView: View {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
||||
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
|
||||
@@ -604,9 +604,9 @@ struct AIKeyboardView: View {
|
||||
@ViewBuilder
|
||||
private var contextArea: some View {
|
||||
ZStack {
|
||||
if showsOOBEClipboardSkill, activeStatus == nil {
|
||||
if showsOOBEClipboardSkill, !hasActiveStatus {
|
||||
clipboardSkillPager
|
||||
} else if let onboardingPracticeHint, activeStatus == nil {
|
||||
} else if let onboardingPracticeHint, !hasActiveStatus {
|
||||
Text(onboardingPracticeHint)
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -636,8 +636,12 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
} else if let content = semanticBadgeContent {
|
||||
semanticBadge(content)
|
||||
} else if let status = activeStatus {
|
||||
statusText(status.text, color: status.color)
|
||||
} else if hasActiveStatus {
|
||||
LiveTranscriptReader(state: state) { transcript in
|
||||
if let status = activeStatus(lastTranscript: transcript) {
|
||||
statusText(status.text, color: status.color)
|
||||
}
|
||||
}
|
||||
} else if showsLongPressCoach {
|
||||
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
|
||||
.font(TypeStyle.footnote.weight(.medium))
|
||||
@@ -720,7 +724,14 @@ struct AIKeyboardView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var activeStatus: (text: String, color: Color)? {
|
||||
/// Whether a status line replaces the idle content. Never depends on the
|
||||
/// transcript text, so this surface does not re-render per ASR partial.
|
||||
private var hasActiveStatus: Bool {
|
||||
activeStatus(lastTranscript: "") != nil
|
||||
}
|
||||
|
||||
/// `lastTranscript` is passed in so only `LiveTranscriptReader` tracks it.
|
||||
private func activeStatus(lastTranscript: String) -> (text: String, color: Color)? {
|
||||
if let error = state.aiSession.errorMessage,
|
||||
!error.isEmpty,
|
||||
!ordinaryPipelineIsActive {
|
||||
@@ -757,18 +768,18 @@ struct AIKeyboardView: View {
|
||||
return nil
|
||||
case .requestingPermissions:
|
||||
return (
|
||||
state.lastTranscript.isEmpty
|
||||
lastTranscript.isEmpty
|
||||
? ExtL10n.string("keyboard.placeholder.preparing")
|
||||
: state.lastTranscript,
|
||||
: lastTranscript,
|
||||
palette.textSecondary
|
||||
)
|
||||
case .recording:
|
||||
return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary)
|
||||
return (lastTranscript.isEmpty ? " " : lastTranscript, palette.textPrimary)
|
||||
case .processing:
|
||||
return (
|
||||
state.lastTranscript.isEmpty
|
||||
lastTranscript.isEmpty
|
||||
? ExtL10n.string("keyboard.placeholder.processing")
|
||||
: state.lastTranscript,
|
||||
: lastTranscript,
|
||||
palette.textSecondary
|
||||
)
|
||||
case .error(_, let message):
|
||||
@@ -1149,14 +1160,7 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
private func waveform(color: Color) -> some View {
|
||||
WaveformView(
|
||||
level: state.level,
|
||||
barCount: 7,
|
||||
color: color,
|
||||
active: true
|
||||
)
|
||||
.frame(width: 38, height: 26)
|
||||
.clipped()
|
||||
AssistantWaveform(state: state, color: color)
|
||||
}
|
||||
|
||||
private var microphoneTint: Color {
|
||||
@@ -1562,6 +1566,35 @@ struct AIKeyboardView: View {
|
||||
/// Uses a subview's intrinsic width until it reaches a visual cap. Unlike
|
||||
/// `frame(maxWidth:)`, this layout does not expand short labels to the cap,
|
||||
/// so each capsule and its complete hit target follow the actual content.
|
||||
/// Reads `state.level` in its own body so the 200 ms mic level tick re-renders
|
||||
/// only these bars instead of the whole assistant surface.
|
||||
private struct AssistantWaveform: View {
|
||||
let state: KeyboardState
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
WaveformView(
|
||||
level: state.level,
|
||||
barCount: 7,
|
||||
color: color,
|
||||
active: true
|
||||
)
|
||||
.frame(width: 38, height: 26)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `state.lastTranscript` in its own body so per-word ASR partials
|
||||
/// re-render only the status line.
|
||||
private struct LiveTranscriptReader<Content: View>: View {
|
||||
let state: KeyboardState
|
||||
@ViewBuilder let content: (String) -> Content
|
||||
|
||||
var body: some View {
|
||||
content(state.lastTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IntrinsicWidthCap: Layout {
|
||||
let maxWidth: CGFloat
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GlobeInputModeButton: UIViewRepresentable {
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@Binding var isPressed: Bool
|
||||
|
||||
func makeUIView(context: Context) -> GlobeInputModeButtonView {
|
||||
@@ -41,7 +41,7 @@ struct GlobeInputModeButton: UIViewRepresentable {
|
||||
struct SystemGlobeKey: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@State private var isPressed = false
|
||||
|
||||
var width: CGFloat = 44
|
||||
|
||||
@@ -112,7 +112,7 @@ struct KeyboardTopControls: View {
|
||||
@Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace
|
||||
@Namespace private var fallbackSelectionNamespace
|
||||
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
let palette: ThemePalette
|
||||
|
||||
@@ -12,7 +12,7 @@ struct LastInputEditView: View {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@State private var selectedPage: Int? = 0
|
||||
|
||||
private var palette: ThemePalette {
|
||||
|
||||
Reference in New Issue
Block a user