diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index cef89aa8..e9326709 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -32,8 +32,8 @@ public struct GoalCreateView: View { NavigationStack { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - GoalCreateTitleField(store: store, focusedField: _focusedField) - GoalCreateDescriptionEditor(store: store, focusedField: _focusedField) + GoalCreateTitleField(store: store, focusedField: $focusedField) + GoalCreateDescriptionEditor(store: store, focusedField: $focusedField) GoalCreateStatusField() GoalCreateTodoField(store: store) } @@ -109,7 +109,7 @@ private struct GoalCreateTopBar: View { private struct GoalCreateTitleField: View { @Bindable var store: StoreOf - @FocusState var focusedField: GoalCreateField? + let focusedField: FocusState.Binding var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -120,7 +120,7 @@ private struct GoalCreateTitleField: View { RecordPresentation.text("development_goal_create_title_placeholder"), text: $store.title ) - .focused($focusedField, equals: .title) + .focused(focusedField, equals: .title) .font(.body) .padding() .background(Color.surface, in: .rect(cornerRadius: 24)) @@ -131,7 +131,7 @@ private struct GoalCreateTitleField: View { private struct GoalCreateDescriptionEditor: View { @Bindable var store: StoreOf - @FocusState var focusedField: GoalCreateField? + let focusedField: FocusState.Binding var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -139,16 +139,38 @@ private struct GoalCreateDescriptionEditor: View { .font(.headline) VStack(spacing: 16) { - GoalCreateModePicker(store: store, focusedField: _focusedField) + GoalCreateModePicker(store: store, focusedField: focusedField) Group { switch store.selectedTab { case .write: - TextEditor(text: $store.markdownContent) - .focused($focusedField, equals: .content) - .font(.body) - .scrollContentBackground(.hidden) - .padding(12) + TextEditorContentLayout(minimumHeight: 120 - 24) { + Text(RecordPresentation.text("development_goal_create_markdown_hint")) + .font(.caption) + .foregroundStyle(Color.textTertiary) + UIKitTextEditor() + .composable( + update: { textEditor in + textEditor.updateInput( + text: $store.markdownContent, + isFocused: focusedField.wrappedValue == .content, + onFocusChange: { isFocused in + if isFocused { + focusedField.wrappedValue = .content + } else if focusedField.wrappedValue == .content { + focusedField.wrappedValue = nil + } + }, + isEnabled: !store.isSaving + ) + }, + sizeThatFits: { UIKitTextEditor.fittingSize(proposal: $0, textEditor: $1) } + ) + .focused(focusedField, equals: .content) + } + .padding(12) + .contentShape(.rect) + .onTapGesture { focusedField.wrappedValue = .content } case .preview: Group { if store.markdownContent.isEmpty { @@ -167,13 +189,8 @@ private struct GoalCreateDescriptionEditor: View { } } } - .frame(minHeight: 120) + .frame(minHeight: 120, alignment: .topLeading) .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) - - Text(RecordPresentation.text("development_goal_create_markdown_hint")) - .font(.caption) - .foregroundStyle(Color.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) } .padding(16) .background(Color.surface, in: .rect(cornerRadius: 24)) @@ -184,7 +201,7 @@ private struct GoalCreateDescriptionEditor: View { private struct GoalCreateModePicker: View { @Bindable var store: StoreOf - @FocusState var focusedField: GoalCreateField? + let focusedField: FocusState.Binding var body: some View { HStack(spacing: 0) { @@ -193,13 +210,13 @@ private struct GoalCreateModePicker: View { isSelected: store.selectedTab == .write ) { store.send(.binding(.set(\.selectedTab, .write))) - focusedField = .content + focusedField.wrappedValue = .content } GoalCreateModeButton( title: RecordPresentation.text("development_record_preview"), isSelected: store.selectedTab == .preview ) { - focusedField = nil + focusedField.wrappedValue = nil store.send(.binding(.set(\.selectedTab, .preview))) } } diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 51cf7c8c..ca8fbe2e 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -138,13 +138,35 @@ public struct RecordEditorView: View { switch store.selectedTab { case .write: - TextEditor(text: $store.markdownContent) - .focused($focusedField, equals: .content) - .font(.body) - .scrollContentBackground(.hidden) - .padding(12) - .frame(minHeight: 340, alignment: .topLeading) - .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + TextEditorContentLayout(minimumHeight: 340 - 24) { + Text(RecordPresentation.text("development_record_markdown_hint")) + .font(.caption) + .foregroundStyle(Color.textTertiary) + UIKitTextEditor() + .composable( + update: { textEditor in + textEditor.updateInput( + text: $store.markdownContent, + isFocused: focusedField == .content, + onFocusChange: { isFocused in + if isFocused { + focusedField = .content + } else if focusedField == .content { + focusedField = nil + } + }, + isEnabled: !store.isLoading + ) + }, + sizeThatFits: { UIKitTextEditor.fittingSize(proposal: $0, textEditor: $1) } + ) + .focused($focusedField, equals: .content) + } + .padding(12) + .frame(minHeight: 340, alignment: .topLeading) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + .contentShape(.rect) + .onTapGesture { focusedField = .content } case .preview: Group { if store.markdownContent.isEmpty { @@ -164,10 +186,6 @@ public struct RecordEditorView: View { .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) } - Text(RecordPresentation.text("development_record_markdown_hint")) - .font(.caption) - .foregroundStyle(Color.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) } .padding(20) .background { diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift index 259c2a91..17ce98d5 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift @@ -8,55 +8,60 @@ import SwiftUI import UIComposable -final class UIKitTextEditor: UITextView, UICoordinatedComposable { - var textBinding: Binding? - var isEditorFocused = false - var onFocusChange: ((Bool) -> Void)? - var placeholder = "" +public final class UIKitTextEditor: UITextView, UICoordinatedComposable { + private var textBinding: Binding? + private var isEditorFocused = false + private var onFocusChange: ((Bool) -> Void)? + private var placeholder: String? - func makeCoordinator() -> Coordinator { + public func makeCoordinator() -> Coordinator { Coordinator() } - func connect(coordinator: Coordinator) { + public func connect(coordinator: Coordinator) { delegate = coordinator coordinator.textView = self configureAppearance() } - func update(coordinator: Coordinator) { + public func update(coordinator: Coordinator) { coordinator.update() } - func disconnect(coordinator: Coordinator) { + public func disconnect(coordinator: Coordinator) { if delegate === coordinator { delegate = nil } coordinator.disconnect() } - func updateInput( + public func updateInput( text: Binding, isFocused: Bool, onFocusChange: @escaping (Bool) -> Void, - placeholder: String + placeholder: String? = nil, + isEnabled: Bool = true ) { textBinding = text isEditorFocused = isFocused self.onFocusChange = onFocusChange self.placeholder = placeholder + isEditable = isEnabled + isSelectable = isEnabled } - static func fittingSize( + public static func fittingSize( proposal: ProposedViewSize, textEditor: UIKitTextEditor ) -> CGSize? { - guard let width = proposal.width else { return nil } + guard let width = proposal.width, width.isFinite, 0 < width else { return nil } let size = textEditor.sizeThatFits( CGSize(width: width, height: .greatestFiniteMagnitude) ) - return CGSize(width: width, height: size.height) + let proposedHeight = proposal.height ?? 0 + let height = proposedHeight.isFinite ? max(size.height, proposedHeight) : size.height + return CGSize(width: width, height: height) } private func configureAppearance() { @@ -74,7 +79,7 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { setContentHuggingPriority(.defaultLow, for: .horizontal) } - final class Coordinator: NSObject, UITextViewDelegate { + public final class Coordinator: NSObject, UITextViewDelegate { weak var textView: UIKitTextEditor? private weak var scrollView: UIScrollView? private var offsetObservation: NSKeyValueObservation? @@ -109,12 +114,12 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { textView = nil } - func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { startTrackingOffset(for: textView) return true } - func textViewDidBeginEditing(_ textView: UITextView) { + public func textViewDidBeginEditing(_ textView: UITextView) { guard let textView = textView as? UIKitTextEditor else { return } if isShowingPlaceholder(in: textView) { @@ -134,14 +139,14 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { } } - func textViewDidChange(_ textView: UITextView) { + public func textViewDidChange(_ textView: UITextView) { guard let textView = textView as? UIKitTextEditor else { return } stopTrackingOffset() textView.textBinding?.wrappedValue = textView.text } - func textViewDidEndEditing(_ textView: UITextView) { + public func textViewDidEndEditing(_ textView: UITextView) { guard let textView = textView as? UIKitTextEditor else { return } if textView.isEditorFocused { @@ -154,10 +159,11 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { } func applyPlaceholderIfNeeded(to textView: UIKitTextEditor) { - guard let textBinding = textView.textBinding else { return } + guard let textBinding = textView.textBinding, + let placeholder = textView.placeholder else { return } if textBinding.wrappedValue.isEmpty && !textView.isFirstResponder { - textView.text = textView.placeholder + textView.text = placeholder textView.textColor = .placeholderText } else if isShowingPlaceholder(in: textView) { textView.text = textBinding.wrappedValue @@ -231,6 +237,50 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { } } +/// 안내 문구와 편집기를 순서대로 배치하고 남은 최소 높이를 편집기에 제안합니다. +public struct TextEditorContentLayout: Layout { + private let minimumHeight: CGFloat + private let spacing = CGFloat(8) + + public init(minimumHeight: CGFloat) { + self.minimumHeight = max(0, minimumHeight) + } + + public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard subviews.count == 2 else { return .zero } + + let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } + ?? subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0 + let hintSize = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil)) + let editorSize = subviews[1].sizeThatFits(ProposedViewSize( + width: width, + height: max(0, minimumHeight - hintSize.height - spacing) + )) + + return CGSize(width: width, height: max(minimumHeight, hintSize.height + spacing + editorSize.height)) + } + + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard subviews.count == 2 else { return } + + let hintProposal = ProposedViewSize(width: bounds.width, height: nil) + let hintSize = subviews[0].sizeThatFits(hintProposal) + subviews[0].place( + at: CGPoint(x: bounds.minX, y: bounds.minY), + anchor: .topLeading, + proposal: hintProposal + ) + subviews[1].place( + at: CGPoint(x: bounds.minX, y: bounds.minY + hintSize.height + spacing), + anchor: .topLeading, + proposal: ProposedViewSize( + width: bounds.width, + height: max(0, bounds.height - hintSize.height - spacing) + ) + ) + } +} + private extension UIView { var enclosingScrollView: UIScrollView? { var currentSuperview = superview diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index c40217d6..8f4656c9 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -332,7 +332,7 @@ private struct ContentView: View { let isContentFocused = field == .content Group { if store.tabViewTag == .editor { - VStack(alignment: .leading, spacing: 8) { + TextEditorContentLayout(minimumHeight: max(0, minimumHeight - 20)) { markdownHint UIKitTextEditor() .composable(