From 8231750cffc0dcb786ababb14578c167c1f3530a Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 24 Mar 2026 14:38:23 -0500 Subject: [PATCH] Simplify guided reflection keyboard toolbar to dismiss-only icon Remove Back/Next/Done buttons from keyboard toolbar to eliminate confusion with the bottom action bar. Toolbar now shows only a keyboard.chevron.compact.down dismiss icon. Co-Authored-By: Claude Opus 4.6 (1M context) --- Shared/Views/ChipSelectionView.swift | 41 +- Shared/Views/GuidedReflectionView.swift | 807 ++++++++++++++++++------ 2 files changed, 613 insertions(+), 235 deletions(-) diff --git a/Shared/Views/ChipSelectionView.swift b/Shared/Views/ChipSelectionView.swift index 50478c6..cccf104 100644 --- a/Shared/Views/ChipSelectionView.swift +++ b/Shared/Views/ChipSelectionView.swift @@ -2,7 +2,7 @@ // ChipSelectionView.swift // Reflect // -// Reusable chip selection grid for guided reflection quick-pick answers. +// Reusable chip grid for guided reflection — tapping a chip appends its text. // import SwiftUI @@ -11,8 +11,8 @@ struct ChipSelectionView: View { let chips: QuestionChips let accentColor: Color - @Binding var selectedChips: [String] @Binding var textAnswer: String + var onInsert: ((String) -> Void)? = nil @State private var showMore = false var body: some View { @@ -56,9 +56,8 @@ struct ChipSelectionView: View { } private func chipButton(_ label: String) -> some View { - let isSelected = selectedChips.contains(label) - return Button { - toggleChip(label) + Button { + appendChip(label) } label: { Text(label) .font(.subheadline) @@ -66,39 +65,27 @@ struct ChipSelectionView: View { .padding(.vertical, 8) .background( Capsule() - .fill(isSelected ? accentColor.opacity(0.2) : Color(.systemGray6)) + .fill(Color(.systemGray6)) ) .overlay( Capsule() - .stroke(isSelected ? accentColor : Color(.systemGray4), lineWidth: 1) + .stroke(Color(.systemGray4), lineWidth: 1) ) - .foregroundColor(isSelected ? accentColor : .primary) + .foregroundColor(.primary) } .accessibilityIdentifier(AccessibilityID.GuidedReflection.chip(label: label)) } - // MARK: - Chip Toggle + // MARK: - Append Chip Text - private func toggleChip(_ label: String) { - if let index = selectedChips.firstIndex(of: label) { - selectedChips.remove(at: index) - // Remove from text answer - let separator = ", " - var parts = textAnswer - .components(separatedBy: separator) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - parts.removeAll { $0 == label } - textAnswer = parts.joined(separator: separator) + private func appendChip(_ label: String) { + let trimmed = textAnswer.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + textAnswer = label } else { - selectedChips.append(label) - // Append to text answer - if textAnswer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - textAnswer = label - } else { - textAnswer += ", \(label)" - } + textAnswer = trimmed + "\n" + label } + onInsert?(label) } } diff --git a/Shared/Views/GuidedReflectionView.swift b/Shared/Views/GuidedReflectionView.swift index 4b47f69..25e8048 100644 --- a/Shared/Views/GuidedReflectionView.swift +++ b/Shared/Views/GuidedReflectionView.swift @@ -2,10 +2,11 @@ // GuidedReflectionView.swift // Reflect // -// Card-step guided reflection sheet — one question at a time. +// Data-driven guided reflection flow with stable step state. // import SwiftUI +import UIKit struct GuidedReflectionView: View { @@ -16,90 +17,83 @@ struct GuidedReflectionView: View { let entry: MoodEntryModel - @State private var reflection: GuidedReflection - @State private var currentStep: Int = 0 + @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 - @FocusState private var isTextFieldFocused: Bool - /// Snapshot of the initial state to detect unsaved changes - private let initialReflection: GuidedReflection + private let initialDraft: GuidedReflectionDraft - private let maxCharacters = 500 + 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 totalSteps: Int { reflection.totalQuestions } + 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 { - reflection != initialReflection + 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) - self._reflection = State(initialValue: existing) - self.initialReflection = existing + 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 { - VStack(spacing: 0) { - // Entry header - entryHeader - .padding() - .background(Color(.systemGray6)) + ScrollViewReader { proxy in + reflectionSheetContent(with: proxy) + } + } + } - Divider() - - // Progress dots - progressDots - .padding(.top, 20) - .padding(.bottom, 8) - - // Question + answer area - questionContent - .padding(.horizontal) - - Spacer() - - // Navigation buttons - navigationButtons - .padding() + 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 { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "Cancel")) { - if hasUnsavedChanges { - showDiscardAlert = true - } else { - dismiss() - } - } - .accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton) - } - - ToolbarItem(placement: .primaryAction) { - Button { - showInfoSheet = true - } label: { - Image(systemName: "info.circle") - } - .accessibilityLabel(String(localized: "guided_reflection_about_title")) - } - - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - isTextFieldFocused = false - } - } - } + .toolbar { navigationToolbar } .alert( String(localized: "guided_reflection_unsaved_title"), isPresented: $showDiscardAlert @@ -115,22 +109,91 @@ struct GuidedReflectionView: View { .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 } } - // MARK: - Entry Header + private var reflectionScrollView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + progressSection + + if let step = currentStep { + stepCard(step) + .id(step.id) + } + } + .padding(.horizontal) + .padding(.top, 20) + .padding(.bottom, contentBottomPadding) + } + .scrollDismissesKeyboard(.interactively) + .onScrollPhaseChange(handleScrollPhaseChange) + } + + @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")) + } + } private var entryHeader: some View { HStack(spacing: 12) { Circle() - .fill(moodTint.color(forMood: entry.mood).opacity(0.2)) + .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(moodTint.color(forMood: entry.mood)) + .foregroundColor(accentColor) ) VStack(alignment: .leading, spacing: 4) { @@ -140,185 +203,252 @@ struct GuidedReflectionView: View { Text(entry.moodString) .font(.subheadline) - .foregroundColor(moodTint.color(forMood: entry.mood)) + .foregroundColor(accentColor) } Spacer() } } - // MARK: - Progress Dots + 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) - private var progressDots: some View { - HStack(spacing: 8) { - ForEach(0.. maxCharacters { - reflection.responses[currentStep].answer = String(newValue.prefix(maxCharacters)) - } - } - .accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor) - - HStack { - Spacer() - Text("\(reflection.responses[currentStep].answer.count)/\(maxCharacters)") - .font(.caption) - .foregroundStyle( - reflection.responses[currentStep].answer.count >= maxCharacters ? .red : .secondary - ) - } - } - } - } - - // MARK: - Navigation Buttons - - private var navigationButtons: some View { - HStack { - // Back button - if currentStep > 0 { - Button { - navigateBack() - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - Text(String(localized: "guided_reflection_back")) - } - .font(.body) - .fontWeight(.medium) - } - .accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton) - } - - Spacer() - - // Next / Save button - if currentStep < totalSteps - 1 { - Button { - navigateForward() - } label: { - HStack(spacing: 4) { - Text(String(localized: "guided_reflection_next")) - Image(systemName: "chevron.right") - } - .font(.body) + 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) - } - .accessibilityIdentifier(AccessibilityID.GuidedReflection.nextButton) - } else { - Button { - saveReflection() - } label: { - Text(String(localized: "guided_reflection_save")) - .font(.body) - .fontWeight(.semibold) - } - .disabled(isSaving) - .accessibilityIdentifier(AccessibilityID.GuidedReflection.saveButton) + .foregroundStyle(.secondary) + .tracking(1.5) } + + Text(step.question) + .font(.title3) + .fontWeight(.medium) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStepIndex)) + + editor(for: step) + + 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)) + ) + } + + 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)) } } - // MARK: - Navigation + 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 { + 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 { + 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() { - isTextFieldFocused = false - let animate = !UIAccessibility.isReduceMotionEnabled - if animate { - withAnimation(.easeInOut(duration: 0.3)) { - currentStep += 1 - } - } else { - currentStep += 1 - } - focusTextFieldDelayed() + guard let nextStepID = draft.stepID(after: currentStepID) else { return } + focusedStepID = nil + updateCurrentStep(to: nextStepID) } private func navigateBack() { - isTextFieldFocused = false - let animate = !UIAccessibility.isReduceMotionEnabled - if animate { - withAnimation(.easeInOut(duration: 0.3)) { - currentStep -= 1 - } + 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 { - currentStep -= 1 - } - focusTextFieldDelayed() - } - - private func focusTextFieldDelayed() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - isTextFieldFocused = true + withAnimation(.easeInOut(duration: 0.25)) { + currentStepID = stepID + } } } - // MARK: - Save + 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 json = reflection.encode() - let success = DataController.shared.updateReflection(forDate: entry.forDate, reflectionJSON: json) + let success = DataController.shared.updateReflection( + forDate: entry.forDate, + reflectionJSON: reflection.encode() + ) if success { dismiss() @@ -326,4 +456,265 @@ struct GuidedReflectionView: View { 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 + let question: String + let 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.question == rhs.question && + lhs.label == rhs.label && + lhs.answer == rhs.answer && + lhs.selectedChips == rhs.selectedChips + } + } + + let moodCategory: MoodCategory + var steps: [Step] + var completedAt: Date? + + init(reflection: GuidedReflection) { + moodCategory = reflection.moodCategory + completedAt = reflection.completedAt + + let questions = GuidedReflection.questions(for: reflection.moodCategory) + let labels = reflection.moodCategory.stepLabels + + steps = questions.enumerated().map { index, question in + let existingResponse = reflection.responses.first(where: { $0.id == index }) + ?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil) + + return Step( + id: index, + question: question, + label: labels.indices.contains(index) ? labels[index] : nil, + chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index), + answer: existingResponse?.answer ?? "", + selectedChips: existingResponse?.selectedChips ?? [] + ) + } + } + + 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 + GuidedReflection.Response( + id: step.id, + question: step.question, + answer: step.answer, + selectedChips: step.selectedChips + ) + }, + completedAt: completedAt + ) + } +} + +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() + } + } }