Files
Reflect/Shared/Views/GuidedReflectionView.swift
Trey T cc4143d3ea Expand guided reflection with CBT thought record and distortion routing
Adds a 5-step negative-mood reflection flow with an evidence-examination
step, Socratic templated questions that back-reference prior answers, and
a deterministic cognitive-distortion detector that routes the perspective-
check prompt to a distortion-specific reframe. Includes CBT plan docs,
flowchart, stats research notes, and MCP config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:49:39 -05:00

952 lines
33 KiB
Swift

//
// GuidedReflectionView.swift
// Reflect
//
// Data-driven guided reflection flow with stable step state.
//
import SwiftUI
import UIKit
struct GuidedReflectionView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
let entry: MoodEntryModel
@State private var draft: GuidedReflectionDraft
@State private var currentStepID: Int
@State private var focusedStepID: Int?
@State private var keyboardHeight: CGFloat = 0
@State private var isSaving = false
@State private var showDiscardAlert = false
@State private var showInfoSheet = false
@State private var showFeedback = false
@State private var savedReflection: GuidedReflection?
private let initialDraft: GuidedReflectionDraft
private static let maxCharacters = 500
private static let minimumEditorHeight: CGFloat = 160
private var textColor: Color { theme.currentTheme.labelColor }
private var accentColor: Color { moodTint.color(forMood: entry.mood) }
private var currentStepIndex: Int {
draft.index(forStepID: currentStepID) ?? 0
}
private var currentStep: GuidedReflectionDraft.Step? {
draft.step(forStepID: currentStepID)
}
private var totalSteps: Int {
draft.steps.count
}
private var hasUnsavedChanges: Bool {
draft != initialDraft
}
private var currentStepHasText: Bool {
currentStep?.hasAnswer ?? false
}
private var canGoBack: Bool {
draft.stepID(before: currentStepID) != nil
}
private var isLastStep: Bool {
draft.stepID(after: currentStepID) == nil
}
init(entry: MoodEntryModel) {
self.entry = entry
let existing = entry.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }
?? GuidedReflection.createNew(for: entry.mood)
let draft = GuidedReflectionDraft(reflection: existing)
let startingStepID = draft.firstUnansweredStepID ?? draft.steps.first?.id ?? 0
self._draft = State(initialValue: draft)
self._currentStepID = State(initialValue: startingStepID)
self._focusedStepID = State(initialValue: nil)
self.initialDraft = draft
}
var body: some View {
NavigationStack {
ZStack {
ScrollViewReader { proxy in
reflectionSheetContent(with: proxy)
}
.blur(radius: showFeedback ? 6 : 0)
.allowsHitTesting(!showFeedback)
if showFeedback, let savedReflection {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture { }
ReflectionFeedbackView(
mood: entry.mood,
reflection: savedReflection,
onDismiss: { dismiss() }
)
}
}
}
}
private func reflectionSheetContent(with proxy: ScrollViewProxy) -> some View {
sheetLayout
.safeAreaInset(edge: .bottom, spacing: 0) {
if keyboardHeight == 0 {
actionBar
}
}
.navigationTitle(String(localized: "guided_reflection_title"))
.navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.sheet)
.toolbar { navigationToolbar }
.alert(
String(localized: "guided_reflection_unsaved_title"),
isPresented: $showDiscardAlert
) {
Button(String(localized: "guided_reflection_discard"), role: .destructive) {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.discardButton)
Button(String(localized: "Cancel"), role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.GuidedReflection.keepEditingButton)
} message: {
Text(String(localized: "guided_reflection_unsaved_message"))
}
.trackScreen(.guidedReflection)
.sheet(isPresented: $showInfoSheet) {
GuidedReflectionInfoView()
}
.onChange(of: currentStepID) { _, newStepID in
DispatchQueue.main.async {
scrollTo(stepID: newStepID, with: proxy)
}
}
.onChange(of: focusedStepID) { _, newFocusedStepID in
guard let newFocusedStepID else { return }
DispatchQueue.main.async {
scrollFocusedEditor(stepID: newFocusedStepID, with: proxy)
}
}
.onChange(of: keyboardHeight) { _, newKeyboardHeight in
guard newKeyboardHeight > 0, let focusedStepID else { return }
DispatchQueue.main.async {
scrollFocusedEditor(stepID: focusedStepID, with: proxy)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
keyboardHeight = keyboardOverlap(from: notification)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
}
}
private var sheetLayout: some View {
VStack(spacing: 0) {
entryHeader
.padding()
.background(Color(.systemGray6))
Divider()
reflectionScrollView
}
}
private var reflectionScrollView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
progressSection
// Pre-intensity rating shown only on the first step, once.
// Captures the baseline emotional intensity so we can measure shift.
if currentStepIndex == 0 {
intensityCard(
title: String(localized: "guided_reflection_pre_intensity_title"),
value: preIntensityBinding
)
}
if let step = currentStep {
stepCard(step)
.id(step.id)
}
// Post-intensity rating shown on the final step, below the question.
// Measures how much the reflection shifted the feeling.
if isLastStep {
intensityCard(
title: String(localized: "guided_reflection_post_intensity_title"),
value: postIntensityBinding
)
}
}
.padding(.horizontal)
.padding(.top, 20)
.padding(.bottom, contentBottomPadding)
}
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange(handleScrollPhaseChange)
}
// MARK: - Intensity Rating UI
private var preIntensityBinding: Binding<Int> {
Binding(
get: { draft.preIntensity ?? 5 },
set: { draft.preIntensity = $0 }
)
}
private var postIntensityBinding: Binding<Int> {
Binding(
get: { draft.postIntensity ?? 5 },
set: { draft.postIntensity = $0 }
)
}
@ViewBuilder
private func intensityCard(title: String, value: Binding<Int>) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(textColor)
HStack {
Text(String(localized: "guided_reflection_intensity_low"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(value.wrappedValue) / 10")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(accentColor)
Spacer()
Text(String(localized: "guided_reflection_intensity_high"))
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value.wrappedValue) },
set: { value.wrappedValue = Int($0.rounded()) }
),
in: 0...10,
step: 1
)
.tint(accentColor)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.secondarySystemBackground))
)
}
@ToolbarContentBuilder
private var navigationToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) {
handleCancel()
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton)
}
ToolbarItem(placement: .primaryAction) {
Button {
showInfoSheet = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel(String(localized: "guided_reflection_about_title"))
.accessibilityIdentifier(AccessibilityID.GuidedReflection.infoButton)
}
}
private var entryHeader: some View {
HStack(spacing: 12) {
Circle()
.fill(accentColor.opacity(0.2))
.frame(width: 50, height: 50)
.overlay(
imagePack.icon(forMood: entry.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(accentColor)
)
VStack(alignment: .leading, spacing: 4) {
Text(entry.forDate, format: .dateTime.weekday(.wide).month().day().year())
.font(.headline)
.foregroundColor(textColor)
Text(entry.moodString)
.font(.subheadline)
.foregroundColor(accentColor)
}
Spacer()
}
}
private var progressSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
Text("\(currentStepIndex + 1) / \(max(totalSteps, 1))")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text(draft.moodCategory.techniqueName)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
HStack(spacing: 8) {
ForEach(draft.steps) { step in
Capsule()
.fill(progressColor(for: step))
.frame(maxWidth: .infinity)
.frame(height: 10)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
}
private func stepCard(_ step: GuidedReflectionDraft.Step) -> some View {
VStack(alignment: .leading, spacing: 18) {
if let label = step.label {
Text(label.uppercased())
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.tracking(1.5)
}
// Resolve the template against current answers so Socratic back-references
// (e.g., "Looking at '<your thought>' again...") reflect edits in real time.
Text(draft.resolvedQuestion(for: step))
.font(.title3)
.fontWeight(.medium)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStepIndex))
editor(for: step)
// Specificity probe gentle nudge if the Q1 (situation) answer is too vague.
// CBT works better on concrete events than generalized feelings.
if step.id == 0 && needsSpecificityProbe(for: step.answer) {
specificityProbe
}
if let chips = step.chips {
ChipSelectionView(
chips: chips,
accentColor: accentColor,
textAnswer: answerBinding(for: step.id),
onInsert: { chip in
handleChipInsertion(chip, into: step.id)
}
)
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Color(.secondarySystemBackground))
)
}
// MARK: - Specificity Probe
/// Vague phrases that should trigger the specificity nudge even if the text is
/// technically long enough. Matched case-insensitively against a trimmed answer.
private static let vaguePhrases: Set<String> = [
"idk", "i don't know", "i dont know",
"nothing", "everything", "nothing really",
"same as always", "same old", "dunno", "no idea"
]
private func needsSpecificityProbe(for answer: String) -> Bool {
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false } // don't nag before they've started
if trimmed.count < 25 { return true }
let lower = trimmed.lowercased()
return Self.vaguePhrases.contains(where: { lower == $0 || lower.hasPrefix($0 + " ") })
}
private var specificityProbe: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(accentColor)
.font(.footnote)
Text(String(localized: "guided_reflection_specificity_probe"))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(accentColor.opacity(0.08))
)
}
private func editor(for step: GuidedReflectionDraft.Step) -> some View {
VStack(alignment: .leading, spacing: 10) {
AutoSizingReflectionTextEditor(
text: answerBinding(for: step.id),
isFocused: focusBinding(for: step.id),
maxCharacters: Self.maxCharacters,
onDone: { focusedStepID = nil }
)
.id(step.id)
.frame(height: Self.minimumEditorHeight, alignment: .top)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(.systemGray4), lineWidth: 1)
)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor)
HStack {
Spacer()
Text("\(step.characterCount)/\(Self.maxCharacters)")
.font(.caption)
.foregroundStyle(step.characterCount >= Self.maxCharacters ? .red : .secondary)
}
Color.clear
.frame(height: 1)
.id(editorAnchorID(for: step.id))
}
}
private var actionBar: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
if canGoBack {
Button(String(localized: "guided_reflection_back")) {
navigateBack()
}
.buttonStyle(.bordered)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton)
}
Spacer(minLength: 0)
Button(isLastStep ? String(localized: "guided_reflection_save") : String(localized: "guided_reflection_next")) {
handlePrimaryAction()
}
.buttonStyle(.borderedProminent)
.tint(isLastStep ? .green : accentColor)
.disabled(primaryActionDisabled)
.accessibilityIdentifier(isLastStep ? AccessibilityID.GuidedReflection.saveButton : AccessibilityID.GuidedReflection.nextButton)
}
.padding(.horizontal)
.padding(.top, 12)
.padding(.bottom, 12)
}
.background(.regularMaterial)
}
private var primaryActionDisabled: Bool {
if isLastStep {
return !draft.isComplete || isSaving
}
return !currentStepHasText
}
private var contentBottomPadding: CGFloat {
28 + (keyboardHeight > 0 ? keyboardHeight + 48 : 0)
}
private func answerBinding(for stepID: Int) -> Binding<String> {
Binding(
get: {
draft.answer(forStepID: stepID) ?? ""
},
set: { newValue in
let truncated = String(newValue.prefix(Self.maxCharacters))
draft.updateAnswer(truncated, forStepID: stepID)
}
)
}
private func focusBinding(for stepID: Int) -> Binding<Bool> {
Binding(
get: {
focusedStepID == stepID
},
set: { isFocused in
if isFocused {
focusedStepID = stepID
} else if focusedStepID == stepID {
focusedStepID = nil
}
}
)
}
private func progressColor(for step: GuidedReflectionDraft.Step) -> Color {
if step.id == currentStepID {
return accentColor
}
if step.hasAnswer {
return accentColor.opacity(0.35)
}
return Color(.systemGray5)
}
private func handleCancel() {
focusedStepID = nil
if hasUnsavedChanges {
showDiscardAlert = true
} else {
dismiss()
}
}
private func navigateForward() {
guard let nextStepID = draft.stepID(after: currentStepID) else { return }
focusedStepID = nil
// When leaving Q2 on the negative path, classify the automatic thought and
// swap Q3's template to the tailored reframe prompt. Idempotent and safe
// to run on every forward navigation.
if draft.moodCategory == .negative && currentStepID == 1 {
draft.recomputeDistortion()
}
updateCurrentStep(to: nextStepID)
}
private func navigateBack() {
guard let previousStepID = draft.stepID(before: currentStepID) else { return }
focusedStepID = nil
updateCurrentStep(to: previousStepID)
}
private func updateCurrentStep(to stepID: Int) {
if UIAccessibility.isReduceMotionEnabled {
currentStepID = stepID
} else {
withAnimation(.easeInOut(duration: 0.25)) {
currentStepID = stepID
}
}
}
private func handleChipInsertion(_ chip: String, into stepID: Int) {
focusedStepID = nil
draft.registerChip(chip, forStepID: stepID)
}
private func handleScrollPhaseChange(oldPhase: ScrollPhase, newPhase: ScrollPhase, context: ScrollPhaseChangeContext) {
guard newPhase == .tracking || newPhase == .interacting else { return }
focusedStepID = nil
}
private func handlePrimaryAction() {
if isLastStep {
saveReflection()
} else {
navigateForward()
}
}
private func saveReflection() {
guard !isSaving else { return }
isSaving = true
focusedStepID = nil
var reflection = draft.makeReflection()
if reflection.isComplete {
reflection.completedAt = Date()
}
let success = DataController.shared.updateReflection(
forDate: entry.forDate,
reflectionJSON: reflection.encode()
)
if success {
// Fire-and-forget tag extraction
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall {
Task {
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
}
}
// Show AI feedback if reflection is complete and AI is potentially available
if reflection.isComplete {
savedReflection = reflection
withAnimation(.easeInOut(duration: 0.3)) {
showFeedback = true
}
} else {
dismiss()
}
} else {
isSaving = false
}
}
private func scrollTo(stepID: Int, with proxy: ScrollViewProxy) {
if UIAccessibility.isReduceMotionEnabled {
proxy.scrollTo(stepID, anchor: .top)
} else {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(stepID, anchor: .top)
}
}
}
private func scrollFocusedEditor(stepID: Int, with proxy: ScrollViewProxy) {
let anchorID = editorAnchorID(for: stepID)
if UIAccessibility.isReduceMotionEnabled {
proxy.scrollTo(anchorID, anchor: .bottom)
} else {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(anchorID, anchor: .bottom)
}
}
}
private func editorAnchorID(for stepID: Int) -> String {
"guided_reflection_editor_anchor_\(stepID)"
}
private func keyboardOverlap(from notification: Notification) -> CGFloat {
guard
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else {
return 0
}
let screenHeight = UIScreen.main.bounds.height
return max(0, screenHeight - frame.minY)
}
}
private struct GuidedReflectionDraft: Equatable {
struct Step: Identifiable, Equatable {
let id: Int
/// The template this step renders from. Contains the raw localized text and
/// optional placeholder ref. The user-visible question is computed by calling
/// `GuidedReflectionDraft.resolvedQuestion(for:)` which injects prior answers.
var template: QuestionTemplate
var label: String?
let chips: QuestionChips?
var answer: String
var selectedChips: [String]
var hasAnswer: Bool {
!answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var characterCount: Int {
answer.count
}
static func == (lhs: Step, rhs: Step) -> Bool {
lhs.id == rhs.id &&
lhs.template == rhs.template &&
lhs.label == rhs.label &&
lhs.answer == rhs.answer &&
lhs.selectedChips == rhs.selectedChips
}
}
let moodCategory: MoodCategory
var steps: [Step]
var completedAt: Date?
var preIntensity: Int?
var postIntensity: Int?
var detectedDistortion: CognitiveDistortion?
init(reflection: GuidedReflection) {
moodCategory = reflection.moodCategory
completedAt = reflection.completedAt
preIntensity = reflection.preIntensity
postIntensity = reflection.postIntensity
detectedDistortion = reflection.detectedDistortion
let templates = GuidedReflection.questionTemplates(for: reflection.moodCategory)
let labels = reflection.moodCategory.stepLabels
steps = templates.enumerated().map { index, template in
// Preserve existing answers if reflection is being resumed.
let existingResponse = reflection.responses.first(where: { $0.id == index })
?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil)
return Step(
id: index,
template: template,
label: labels.indices.contains(index) ? labels[index] : nil,
chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index),
answer: existingResponse?.answer ?? "",
selectedChips: existingResponse?.selectedChips ?? []
)
}
// Re-apply any previously-detected distortion so Q3 restores its tailored template.
if let distortion = detectedDistortion, moodCategory == .negative {
applyDistortion(distortion)
}
}
/// Produces (index, answer) tuples suitable for `QuestionTemplate.resolved(with:)`.
private var answerTuples: [(index: Int, text: String)] {
steps.map { ($0.id, $0.answer) }
}
/// Resolves the user-visible question text for a step, injecting the latest
/// value of any referenced prior answer. Called at render time by the view.
func resolvedQuestion(for step: Step) -> String {
step.template.resolved(with: answerTuples)
}
func resolvedQuestion(forStepID stepID: Int) -> String {
guard let step = step(forStepID: stepID) else { return "" }
return resolvedQuestion(for: step)
}
/// Mutating: detect the cognitive distortion in the current Q2 answer (negative path only)
/// and swap Q3's template to the tailored prompt. Safe to call repeatedly if Q2 is empty
/// or detection yields `.unknown` this resets to the fallback template.
mutating func recomputeDistortion() {
guard moodCategory == .negative,
let q2 = steps.first(where: { $0.id == 1 }),
!q2.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
detectedDistortion = nil
applyDistortion(.unknown) // reset Q3 label to generic
return
}
let distortion = CognitiveDistortionDetector.detect(in: q2.answer)
detectedDistortion = distortion == .unknown ? nil : distortion
applyDistortion(distortion)
}
/// Overwrites Q3's template + label based on the detected distortion.
private mutating func applyDistortion(_ distortion: CognitiveDistortion) {
guard let q3Index = steps.firstIndex(where: { $0.id == 2 }) else { return }
steps[q3Index].template = distortion.perspectiveCheckTemplate
if distortion != .unknown {
steps[q3Index].label = distortion.stepLabel
} else {
// Reset to the default "Perspective Check" label from MoodCategory.stepLabels.
let defaults = moodCategory.stepLabels
steps[q3Index].label = defaults.indices.contains(2) ? defaults[2] : nil
}
}
var firstUnansweredStepID: Int? {
steps.first(where: { !$0.hasAnswer })?.id
}
var isComplete: Bool {
steps.count == moodCategory.questionCount && steps.allSatisfy(\.hasAnswer)
}
func step(forStepID stepID: Int) -> Step? {
steps.first(where: { $0.id == stepID })
}
func index(forStepID stepID: Int) -> Int? {
steps.firstIndex(where: { $0.id == stepID })
}
func answer(forStepID stepID: Int) -> String? {
step(forStepID: stepID)?.answer
}
func stepID(before stepID: Int) -> Int? {
guard let index = index(forStepID: stepID), index > 0 else { return nil }
return steps[index - 1].id
}
func stepID(after stepID: Int) -> Int? {
guard let index = index(forStepID: stepID), index < steps.count - 1 else { return nil }
return steps[index + 1].id
}
mutating func updateAnswer(_ answer: String, forStepID stepID: Int) {
guard let index = index(forStepID: stepID) else { return }
steps[index].answer = answer
}
mutating func registerChip(_ chip: String, forStepID stepID: Int) {
guard let index = index(forStepID: stepID) else { return }
if !steps[index].selectedChips.contains(chip) {
steps[index].selectedChips.append(chip)
}
}
func makeReflection() -> GuidedReflection {
GuidedReflection(
moodCategory: moodCategory,
responses: steps.map { step in
// Persist the user-visible resolved question text not the raw template
// so downstream consumers (AI feedback, history view) see what the user saw.
GuidedReflection.Response(
id: step.id,
question: resolvedQuestion(for: step),
answer: step.answer,
selectedChips: step.selectedChips
)
},
completedAt: completedAt,
preIntensity: preIntensity,
postIntensity: postIntensity,
detectedDistortion: detectedDistortion
)
}
}
private struct AutoSizingReflectionTextEditor: UIViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let maxCharacters: Int
let onDone: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isScrollEnabled = true
textView.alwaysBounceVertical = true
textView.font = .preferredFont(forTextStyle: .body)
textView.backgroundColor = .clear
textView.textColor = .label
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.keyboardDismissMode = .interactive
context.coordinator.configureAccessoryView(for: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
if textView.text != text {
textView.text = text
DispatchQueue.main.async {
scrollCaretToVisible(in: textView)
}
}
if isFocused && !textView.isFirstResponder {
DispatchQueue.main.async {
textView.becomeFirstResponder()
scrollCaretToVisible(in: textView)
}
} else if !isFocused && textView.isFirstResponder {
DispatchQueue.main.async {
textView.resignFirstResponder()
}
}
}
private func scrollCaretToVisible(in textView: UITextView) {
guard let selectedTextRange = textView.selectedTextRange else { return }
textView.layoutIfNeeded()
let caretRect = textView.caretRect(for: selectedTextRange.end)
let paddedRect = caretRect.insetBy(dx: 0, dy: -16)
textView.scrollRectToVisible(paddedRect, animated: false)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: AutoSizingReflectionTextEditor
weak var textView: UITextView?
private let accessoryToolbar = UIToolbar()
private let spacerItem = UIBarButtonItem(systemItem: .flexibleSpace)
private lazy var dismissButtonItem = UIBarButtonItem(
image: UIImage(systemName: "keyboard.chevron.compact.down"),
style: .plain,
target: self,
action: #selector(handleDoneTapped)
)
init(parent: AutoSizingReflectionTextEditor) {
self.parent = parent
}
func configureAccessoryView(for textView: UITextView) {
self.textView = textView
accessoryToolbar.sizeToFit()
accessoryToolbar.setItems([spacerItem, dismissButtonItem], animated: false)
textView.inputAccessoryView = accessoryToolbar
}
func textViewDidBeginEditing(_ textView: UITextView) {
if !parent.isFocused {
parent.isFocused = true
}
parent.scrollCaretToVisible(in: textView)
}
func textViewDidEndEditing(_ textView: UITextView) {
if parent.isFocused {
parent.isFocused = false
}
}
func textViewDidChange(_ textView: UITextView) {
let truncated = String(textView.text.prefix(parent.maxCharacters))
if textView.text != truncated {
textView.text = truncated
}
if parent.text != truncated {
parent.text = truncated
}
parent.scrollCaretToVisible(in: textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard textView.isFirstResponder else { return }
parent.scrollCaretToVisible(in: textView)
}
@objc private func handleDoneTapped() {
parent.onDone()
}
}
}