diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift index b2257698..259c2a91 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift @@ -6,156 +6,107 @@ // import SwiftUI -import UIKit -import Domain +import UIComposable -struct UIKitTextEditor: View { - @Binding var text: String - @Environment(\.uiKitTextEditorFocusBinding) private var focusBinding - @State private var minHeight = TextEditorMetrics.font.lineHeight - private let placeholder: String +final class UIKitTextEditor: UITextView, UICoordinatedComposable { + var textBinding: Binding? + var isEditorFocused = false + var onFocusChange: ((Bool) -> Void)? + var placeholder = "" - init( - text: Binding, - placeholder: String = "" - ) { - self._text = text - self.placeholder = placeholder - } - - var body: some View { - UIKitTextEditorRepresentable( - text: $text, - minHeight: $minHeight, - focusBinding: focusBinding, - placeholder: placeholder - ) - .frame(maxWidth: .infinity, minHeight: minHeight) - } - - // 각 메서드 내에 있는 `.focused()`의 정체 - // 해당 .focused()는 SwiftUI의 모디파이어 - // 이 뷰를 SwiftUI 포커스 시스템에 실제 포커스 타겟으로 등록해주는 역할을 함 - - func focused(_ condition: FocusState.Binding) -> some View { - modifier(TextEditorFocusModifier( - focusBinding: Binding(condition) - )) - .focused(condition) + func makeCoordinator() -> Coordinator { + Coordinator() } - func focused( - _ binding: FocusState.Binding, - equals value: Value - ) -> some View where Value: Hashable & ExpressibleByNilLiteral { - modifier(TextEditorFocusModifier( - focusBinding: Binding( - binding, - equals: value - ) - )) - .focused(binding, equals: value) + func connect(coordinator: Coordinator) { + delegate = coordinator + coordinator.textView = self + configureAppearance() } -} -private enum TextEditorMetrics { - static let font = UIFont.preferredFont(forTextStyle: .body) -} - -private struct TextEditorFocusModifier: ViewModifier { - let focusBinding: Binding - - func body(content: Content) -> some View { - content - .environment(\.uiKitTextEditorFocusBinding, focusBinding) + func update(coordinator: Coordinator) { + coordinator.update() } -} -private struct TextEditorFocusBindingKey: EnvironmentKey { - static let defaultValue: Binding? = nil -} - -private extension EnvironmentValues { - var uiKitTextEditorFocusBinding: Binding? { - get { self[TextEditorFocusBindingKey.self] } - set { self[TextEditorFocusBindingKey.self] = newValue } + func disconnect(coordinator: Coordinator) { + if delegate === coordinator { + delegate = nil + } + coordinator.disconnect() } -} -private struct UIKitTextEditorRepresentable: UIViewRepresentable { - @Binding var text: String - @Binding var minHeight: CGFloat - private let focusBinding: Binding? - private let placeholder: String - - init( + func updateInput( text: Binding, - minHeight: Binding, - focusBinding: Binding?, + isFocused: Bool, + onFocusChange: @escaping (Bool) -> Void, placeholder: String ) { - self._text = text - self.focusBinding = focusBinding - self._minHeight = minHeight + textBinding = text + isEditorFocused = isFocused + self.onFocusChange = onFocusChange self.placeholder = placeholder } - func makeCoordinator() -> Coordinator { - Coordinator(self) + static func fittingSize( + proposal: ProposedViewSize, + textEditor: UIKitTextEditor + ) -> CGSize? { + guard let width = proposal.width else { return nil } + + let size = textEditor.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + return CGSize(width: width, height: size.height) } - func makeUIView(context: Context) -> UITextView { - let textView = UITextView() - textView.delegate = context.coordinator - textView.font = TextEditorMetrics.font - textView.backgroundColor = .clear - textView.textColor = .label - textView.tintColor = .tintColor - textView.textContainer.lineFragmentPadding = 0 - textView.textContainer.widthTracksTextView = true - textView.textContainer.lineBreakMode = .byWordWrapping - textView.textContainerInset = .zero - textView.isScrollEnabled = false - textView.autocorrectionType = .no - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - context.coordinator.applyPlaceholderIfNeeded(to: textView) - return textView + private func configureAppearance() { + font = UIFont.preferredFont(forTextStyle: .body) + backgroundColor = .clear + textColor = .label + tintColor = .tintColor + textContainer.lineFragmentPadding = 0 + textContainer.widthTracksTextView = true + textContainer.lineBreakMode = .byWordWrapping + textContainerInset = .zero + isScrollEnabled = false + autocorrectionType = .no + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + setContentHuggingPriority(.defaultLow, for: .horizontal) } - func updateUIView(_ uiView: UITextView, context: Context) { - context.coordinator.parent = self + final class Coordinator: NSObject, UITextViewDelegate { + weak var textView: UIKitTextEditor? + private weak var scrollView: UIScrollView? + private var offsetObservation: NSKeyValueObservation? + private var trackedOffset: CGPoint? + private var isRestoringOffset = false - if !context.coordinator.isShowingPlaceholder(in: uiView) && uiView.text != text { - uiView.text = text - } + func update() { + guard let textView, let textBinding = textView.textBinding else { return } + + if !isShowingPlaceholder(in: textView) && textView.text != textBinding.wrappedValue { + textView.text = textBinding.wrappedValue + } + + applyPlaceholderIfNeeded(to: textView) - context.coordinator.applyPlaceholderIfNeeded(to: uiView) + DispatchQueue.main.async { [weak self, weak textView] in + guard let self, let textView else { return } - DispatchQueue.main.async { - if let focusBinding { - if focusBinding.wrappedValue { - if !uiView.isFirstResponder { - context.coordinator.startTrackingOffset(for: uiView) - uiView.becomeFirstResponder() + if textView.isEditorFocused { + if !textView.isFirstResponder { + self.startTrackingOffset(for: textView) + textView.becomeFirstResponder() } - } else if uiView.isFirstResponder { - uiView.resignFirstResponder() + } else if textView.isFirstResponder { + textView.resignFirstResponder() } } - context.coordinator.updateHeight(for: uiView) } - } - final class Coordinator: NSObject, UITextViewDelegate { - var parent: UIKitTextEditorRepresentable - private weak var scrollView: UIScrollView? - private var offsetObservation: NSKeyValueObservation? - private var trackedOffset: CGPoint? - private var isRestoringOffset = false - - init(_ parent: UIKitTextEditorRepresentable) { - self.parent = parent + func disconnect() { + stopTrackingOffset() + textView = nil } func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { @@ -164,44 +115,52 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { } func textViewDidBeginEditing(_ textView: UITextView) { + guard let textView = textView as? UIKitTextEditor else { return } + if isShowingPlaceholder(in: textView) { textView.text = nil textView.textColor = .label } - if let focusBinding = parent.focusBinding, !focusBinding.wrappedValue { - focusBinding.wrappedValue = true + if !textView.isEditorFocused { + textView.isEditorFocused = true + textView.onFocusChange?(true) } restoreOffsetIfNeeded() DispatchQueue.main.async { [weak self] in self?.restoreOffsetIfNeeded() - self?.updateHeight(for: textView) } } func textViewDidChange(_ textView: UITextView) { + guard let textView = textView as? UIKitTextEditor else { return } + stopTrackingOffset() - parent.text = textView.text - updateHeight(for: textView) + textView.textBinding?.wrappedValue = textView.text } func textViewDidEndEditing(_ textView: UITextView) { - if let focusBinding = parent.focusBinding, focusBinding.wrappedValue { - focusBinding.wrappedValue = false + guard let textView = textView as? UIKitTextEditor else { return } + + if textView.isEditorFocused { + textView.isEditorFocused = false + textView.onFocusChange?(false) } stopTrackingOffset() applyPlaceholderIfNeeded(to: textView) } - func applyPlaceholderIfNeeded(to textView: UITextView) { - if parent.text.isEmpty && !textView.isFirstResponder { - textView.text = parent.placeholder + func applyPlaceholderIfNeeded(to textView: UIKitTextEditor) { + guard let textBinding = textView.textBinding else { return } + + if textBinding.wrappedValue.isEmpty && !textView.isFirstResponder { + textView.text = textView.placeholder textView.textColor = .placeholderText } else if isShowingPlaceholder(in: textView) { - textView.text = parent.text + textView.text = textBinding.wrappedValue textView.textColor = .label } } @@ -269,50 +228,6 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { trackedOffset = nil } - func updateHeight(for textView: UITextView) { - textView.layoutIfNeeded() - - let width = textView.bounds.width - guard 0 < width else { return } - - let nextHeight = ceil(textView.sizeThatFits( - CGSize(width: width, height: .greatestFiniteMagnitude) - ).height) - let resolvedHeight = max(nextHeight, TextEditorMetrics.font.lineHeight) - - if parent.minHeight != resolvedHeight { - DispatchQueue.main.async { - self.parent.minHeight = resolvedHeight - } - } - } - } -} - -private extension Binding where Value == Bool { - init(_ binding: FocusState.Binding) { - self.init( - get: { binding.wrappedValue }, - set: { binding.wrappedValue = $0 } - ) - } - - init( - _ binding: FocusState.Binding, - equals value: FocusedValue - ) where FocusedValue: Hashable & ExpressibleByNilLiteral { - self.init( - get: { - binding.wrappedValue == value - }, - set: { isFocused in - if isFocused { - binding.wrappedValue = value - } else if binding.wrappedValue == value { - binding.wrappedValue = nil - } - } - ) } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index cf5db971..c40217d6 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -329,18 +329,34 @@ private struct ContentView: View { let minimumHeight: CGFloat var body: some View { + let isContentFocused = field == .content Group { if store.tabViewTag == .editor { VStack(alignment: .leading, spacing: 8) { markdownHint - UIKitTextEditor( - text: $store.content, - placeholder: String( - localized: "todo_editor_description_optional", - bundle: PresentationResources.bundle + UIKitTextEditor() + .composable( + update: { textEditor in + textEditor.updateInput( + text: $store.content, + isFocused: isContentFocused, + onFocusChange: { isFocused in + if isFocused { + field = .content + } else if field == .content { + field = nil + } + }, + placeholder: String( + localized: "todo_editor_description_optional", + bundle: PresentationResources.bundle + ) + ) + }, + sizeThatFits: { UIKitTextEditor.fittingSize(proposal: $0, textEditor: $1) } ) - ) - .focused($field, equals: .content) + // SwiftUI 포커싱 시스템에 등록하기 위해 사용 + .focused($field, equals: .content) } .padding(.horizontal) } else { diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index bf33cad8..e805ceb9 100644 --- a/Libraries/ThirdParty/Project.swift +++ b/Libraries/ThirdParty/Project.swift @@ -37,6 +37,10 @@ let project = Project( url: "https://github.com/opficdev/Cradle.git", .exact("1.2.0") ), + .package( + url: "https://github.com/opficdev/UIComposable.git", + .exact("0.2.0") + ), ], settings: .devlogProject(additionalBase: deploymentSettings), targets: [ @@ -72,6 +76,7 @@ let project = Project( .package(product: "ComposableArchitecture"), .package(product: "OrderedCollections"), .package(product: "Cradle"), + .package(product: "UIComposable"), ], settings: .devlog( base: deploymentSettings