From fb527efd1b74ef4681d69f39a1564cdbf4c6b0cd Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 21 Sep 2026 17:28:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20UIComposable=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20UIKitTextEditor=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Component/UIKitTextEditor.swift | 277 +++++++----------- .../Sources/Todo/Editor/TodoEditorView.swift | 27 +- Libraries/ThirdParty/Project.swift | 5 + 3 files changed, 127 insertions(+), 182 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift index b2257698..86af801c 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift @@ -6,156 +6,121 @@ // import SwiftUI -import UIKit -import Domain - -struct UIKitTextEditor: View { - @Binding var text: String - @Environment(\.uiKitTextEditorFocusBinding) private var focusBinding - @State private var minHeight = TextEditorMetrics.font.lineHeight - private let placeholder: String - - 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) +import UIComposable + +final class UIKitTextEditor: UITextView, UICoordinatedComposable { + static let minimumHeight = UIFont.preferredFont(forTextStyle: .body).lineHeight + var textBinding: Binding? + var isEditorFocused = false + var onFocusChange: ((Bool) -> Void)? + var placeholder = "" + private var contentHeight = minimumHeight + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) } - // 각 메서드 내에 있는 `.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) + override func layoutSubviews() { + super.layoutSubviews() + updateContentHeight() } - 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 + func updateContentHeight() { + guard 0 < bounds.width else { return } - if !context.coordinator.isShowingPlaceholder(in: uiView) && uiView.text != text { - uiView.text = text - } + let nextHeight = ceil(sizeThatFits( + CGSize(width: bounds.width, height: .greatestFiniteMagnitude) + ).height) + let resolvedHeight = max(nextHeight, Self.minimumHeight) - context.coordinator.applyPlaceholderIfNeeded(to: uiView) + guard contentHeight != resolvedHeight else { return } - DispatchQueue.main.async { - if let focusBinding { - if focusBinding.wrappedValue { - if !uiView.isFirstResponder { - context.coordinator.startTrackingOffset(for: uiView) - uiView.becomeFirstResponder() - } - } else if uiView.isFirstResponder { - uiView.resignFirstResponder() - } - } - context.coordinator.updateHeight(for: uiView) - } + contentHeight = resolvedHeight + invalidateIntrinsicContentSize() } final class Coordinator: NSObject, UITextViewDelegate { - var parent: UIKitTextEditorRepresentable + weak var textView: UIKitTextEditor? private weak var scrollView: UIScrollView? private var offsetObservation: NSKeyValueObservation? private var trackedOffset: CGPoint? private var isRestoringOffset = false - init(_ parent: UIKitTextEditorRepresentable) { - self.parent = parent + 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) + + DispatchQueue.main.async { [weak self, weak textView] in + guard let self, let textView else { return } + + if textView.isEditorFocused { + if !textView.isFirstResponder { + self.startTrackingOffset(for: textView) + textView.becomeFirstResponder() + } + } else if textView.isFirstResponder { + textView.resignFirstResponder() + } + textView.updateContentHeight() + } + } + + func disconnect() { + stopTrackingOffset() + textView = nil } func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { @@ -164,44 +129,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.onFocusChange?(true) } restoreOffsetIfNeeded() DispatchQueue.main.async { [weak self] in self?.restoreOffsetIfNeeded() - self?.updateHeight(for: textView) + textView.updateContentHeight() } } 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 + textView.updateContentHeight() } 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.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 +242,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..67ff155c 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -333,14 +333,25 @@ private struct ContentView: View { if store.tabViewTag == .editor { VStack(alignment: .leading, spacing: 8) { markdownHint - UIKitTextEditor( - text: $store.content, - placeholder: String( - localized: "todo_editor_description_optional", - bundle: PresentationResources.bundle - ) - ) - .focused($field, equals: .content) + UIKitTextEditor(frame: .zero) + .composable { textEditor in + textEditor.updateInput( + text: $store.content, + isFocused: field == .content, + onFocusChange: { isFocused in + if isFocused { + field = .content + } else if field == .content { + field = nil + } + }, + placeholder: String( + localized: "todo_editor_description_optional", + bundle: PresentationResources.bundle + ) + ) + } + .focused($field, equals: .content) } .padding(.horizontal) } else { diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index bf33cad8..3956cf7a 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.1.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 From fe8914a6f65ee4e3b92ea6c4014292073c3d9622 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 21 Sep 2026 18:28:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20UIKitTextEditor=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=B8=A1=EC=A0=95=EA=B3=BC=20=ED=8F=AC=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=20=ED=98=B8=ED=99=98=EC=84=B1=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Component/UIKitTextEditor.swift | 38 ++++++------------ .../Sources/Todo/Editor/TodoEditorView.swift | 39 +++++++++++-------- Libraries/ThirdParty/Project.swift | 2 +- 3 files changed, 35 insertions(+), 44 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift index 86af801c..259c2a91 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/UIKitTextEditor.swift @@ -9,16 +9,10 @@ import SwiftUI import UIComposable final class UIKitTextEditor: UITextView, UICoordinatedComposable { - static let minimumHeight = UIFont.preferredFont(forTextStyle: .body).lineHeight var textBinding: Binding? var isEditorFocused = false var onFocusChange: ((Bool) -> Void)? var placeholder = "" - private var contentHeight = minimumHeight - - override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) - } func makeCoordinator() -> Coordinator { Coordinator() @@ -53,9 +47,16 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { self.placeholder = placeholder } - override func layoutSubviews() { - super.layoutSubviews() - updateContentHeight() + 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) } private func configureAppearance() { @@ -73,20 +74,6 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { setContentHuggingPriority(.defaultLow, for: .horizontal) } - func updateContentHeight() { - guard 0 < bounds.width else { return } - - let nextHeight = ceil(sizeThatFits( - CGSize(width: bounds.width, height: .greatestFiniteMagnitude) - ).height) - let resolvedHeight = max(nextHeight, Self.minimumHeight) - - guard contentHeight != resolvedHeight else { return } - - contentHeight = resolvedHeight - invalidateIntrinsicContentSize() - } - final class Coordinator: NSObject, UITextViewDelegate { weak var textView: UIKitTextEditor? private weak var scrollView: UIScrollView? @@ -114,7 +101,6 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { } else if textView.isFirstResponder { textView.resignFirstResponder() } - textView.updateContentHeight() } } @@ -137,6 +123,7 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { } if !textView.isEditorFocused { + textView.isEditorFocused = true textView.onFocusChange?(true) } @@ -144,7 +131,6 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { DispatchQueue.main.async { [weak self] in self?.restoreOffsetIfNeeded() - textView.updateContentHeight() } } @@ -153,13 +139,13 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable { stopTrackingOffset() textView.textBinding?.wrappedValue = textView.text - textView.updateContentHeight() } func textViewDidEndEditing(_ textView: UITextView) { guard let textView = textView as? UIKitTextEditor else { return } if textView.isEditorFocused { + textView.isEditorFocused = false textView.onFocusChange?(false) } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 67ff155c..c40217d6 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -329,28 +329,33 @@ 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(frame: .zero) - .composable { textEditor in - textEditor.updateInput( - text: $store.content, - isFocused: field == .content, - onFocusChange: { isFocused in - if isFocused { - field = .content - } else if field == .content { - field = nil - } - }, - 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) } + ) + // SwiftUI 포커싱 시스템에 등록하기 위해 사용 .focused($field, equals: .content) } .padding(.horizontal) diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index 3956cf7a..e805ceb9 100644 --- a/Libraries/ThirdParty/Project.swift +++ b/Libraries/ThirdParty/Project.swift @@ -39,7 +39,7 @@ let project = Project( ), .package( url: "https://github.com/opficdev/UIComposable.git", - .exact("0.1.0") + .exact("0.2.0") ), ], settings: .devlogProject(additionalBase: deploymentSettings),