Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts, links) missing accessibility identifiers across 13 view files. Added centralized ID definitions and applied them to every entry detail button, guided reflection control, settings toggle, paywall unlock button, subscription/IAP button, lock screen control, and photo action dialog.
724 lines
24 KiB
Swift
724 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)
|
|
}
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
}
|