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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-24 14:38:23 -05:00
parent a4e25970ee
commit 8231750cff
2 changed files with 613 additions and 235 deletions

View File

@@ -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..<totalSteps, id: \.self) { index in
Circle()
.fill(index == currentStep ? moodTint.color(forMood: entry.mood) : Color(.systemGray4))
.frame(width: 10, height: 10)
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)
}
}
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
}
// MARK: - Question Content
private var questionContent: some View {
VStack(alignment: .leading, spacing: 16) {
if currentStep < reflection.responses.count {
let response = reflection.responses[currentStep]
let stepLabels = reflection.moodCategory.stepLabels
// CBT step label
if currentStep < stepLabels.count {
Text(stepLabels[currentStep].uppercased())
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.tracking(1.5)
}
Text(response.question)
.font(.title3)
.fontWeight(.medium)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep))
.id("question_\(currentStep)")
if let chips = QuestionChips.chips(for: reflection.moodCategory, questionIndex: currentStep) {
ChipSelectionView(
chips: chips,
accentColor: moodTint.color(forMood: entry.mood),
selectedChips: $reflection.responses[currentStep].selectedChips,
textAnswer: $reflection.responses[currentStep].answer
)
}
TextEditor(text: $reflection.responses[currentStep].answer)
.focused($isTextFieldFocused)
.frame(minHeight: 120, maxHeight: 200)
.scrollContentBackground(.hidden)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.systemGray4), lineWidth: 1)
)
.onChange(of: reflection.responses[currentStep].answer) { _, newValue in
if newValue.count > 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<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() {
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()
}
}
}