- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
726 lines
24 KiB
Swift
726 lines
24 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
|
|
|
|
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 {
|
|
ScrollViewReader { proxy in
|
|
reflectionSheetContent(with: proxy)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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"))
|
|
.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)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
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
|
|
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 {
|
|
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
|
|
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()
|
|
}
|
|
}
|
|
}
|