From 7de0d9ffb9a4584461f3eb6fdee5e0985621c995 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 10:01:25 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20UIKitTextEditor=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=20(=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Resource/Localizable.xcstrings | 3 - .../UI/Common/Component/UIKitTextEditor.swift | 204 ++++++++++++++++++ DevLog/UI/Home/TodoEditorView.swift | 34 ++- 3 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 DevLog/UI/Common/Component/UIKitTextEditor.swift diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index f94a144..9f1df69 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -323,9 +323,6 @@ }, "생성일" : { - }, - "설명(선택)" : { - }, "설정" : { diff --git a/DevLog/UI/Common/Component/UIKitTextEditor.swift b/DevLog/UI/Common/Component/UIKitTextEditor.swift new file mode 100644 index 0000000..f44d102 --- /dev/null +++ b/DevLog/UI/Common/Component/UIKitTextEditor.swift @@ -0,0 +1,204 @@ +// +// UIKitTextEditor.swift +// DevLog +// +// Created by opfic on 3/18/26. +// + +import SwiftUI +import UIKit + +struct UIKitTextEditor: View { + @Binding var text: String + @Binding var isFocused: Bool + private let placeholder: String + @State private var minHeight = CGFloat(36) + + init( + text: Binding, + isFocused: Binding, + placeholder: String = "" + ) { + self._text = text + self._isFocused = isFocused + self.placeholder = placeholder + } + + var body: some View { + UIKitTextEditorRepresentable( + text: $text, + minHeight: $minHeight, + isFocused: isFocused, + placeholder: placeholder + ) + .frame(maxWidth: .infinity, minHeight: minHeight) + } +} + +private struct UIKitTextEditorRepresentable: UIViewRepresentable { + @Binding var text: String + @Binding var minHeight: CGFloat + private let isFocused: Bool + private let placeholder: String + + init( + text: Binding, + minHeight: Binding, + isFocused: Bool, + placeholder: String + ) { + self._text = text + self.isFocused = isFocused + self._minHeight = minHeight + self.placeholder = placeholder + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.font = UIFont.preferredFont(forTextStyle: .callout) + 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 + } + + func updateUIView(_ uiView: UITextView, context: Context) { + context.coordinator.parent = self + + if !context.coordinator.isShowingPlaceholder(in: uiView) && uiView.text != text { + uiView.text = text + } + + context.coordinator.applyPlaceholderIfNeeded(to: uiView) + + DispatchQueue.main.async { + if isFocused { + if !uiView.isFirstResponder { + context.coordinator.preserveAncestorScrollOffset(for: uiView) + uiView.becomeFirstResponder() + } + } else if uiView.isFirstResponder { + uiView.resignFirstResponder() + } + context.coordinator.updateHeight(for: uiView) + } + } + + final class Coordinator: NSObject, UITextViewDelegate { + var parent: UIKitTextEditorRepresentable + private weak var ancestorScrollView: UIScrollView? + private var preservedContentOffset: CGPoint? + + init(_ parent: UIKitTextEditorRepresentable) { + self.parent = parent + } + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + preserveAncestorScrollOffset(for: textView) + return true + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if isShowingPlaceholder(in: textView) { + textView.text = nil + textView.textColor = .label + } + + restoreAncestorScrollOffsetIfNeeded() + + DispatchQueue.main.async { [weak self] in + self?.restoreAncestorScrollOffsetIfNeeded() + self?.updateHeight(for: textView) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.restoreAncestorScrollOffsetIfNeeded() + self?.preservedContentOffset = nil + } + } + + func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + updateHeight(for: textView) + } + + func textViewDidEndEditing(_ textView: UITextView) { + applyPlaceholderIfNeeded(to: textView) + } + + func applyPlaceholderIfNeeded(to textView: UITextView) { + if parent.text.isEmpty && !textView.isFirstResponder { + textView.text = parent.placeholder + textView.textColor = .placeholderText + } else if isShowingPlaceholder(in: textView) { + textView.text = parent.text + textView.textColor = .label + } + } + + func isShowingPlaceholder(in textView: UITextView) -> Bool { + textView.textColor == .placeholderText + } + + func preserveAncestorScrollOffset(for textView: UITextView) { + ancestorScrollView = textView.enclosingScrollView + preservedContentOffset = ancestorScrollView?.contentOffset + } + + func restoreAncestorScrollOffsetIfNeeded() { + guard let ancestorScrollView, let preservedContentOffset else { return } + + if ancestorScrollView.contentOffset != preservedContentOffset { + ancestorScrollView.setContentOffset(preservedContentOffset, animated: false) + } + } + + 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, 36) + + if parent.minHeight != resolvedHeight { + DispatchQueue.main.async { + self.parent.minHeight = resolvedHeight + } + } + } + } +} + +private extension UIView { + var enclosingScrollView: UIScrollView? { + var currentSuperview = superview + + while let view = currentSuperview { + if let scrollView = view as? UIScrollView { + return scrollView + } + + currentSuperview = view.superview + } + + return nil + } +} diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index ebae63b..1c975c4 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -15,6 +15,7 @@ struct TodoEditorView: View { @FocusState private var field: Field? private let calendar = Calendar.current var onSubmit: ((Todo) -> Void)? + @State private var isContentFocused = false var body: some View { NavigationStack { @@ -36,6 +37,8 @@ struct TodoEditorView: View { } .onTapGesture { field = .content + // 나중에 제거 + isContentFocused = true } .navigationTitle(viewModel.navigationTitle) .navigationBarTitleDisplayMode(.inline) @@ -62,6 +65,11 @@ struct TodoEditorView: View { } .disabled(!viewModel.isReadyToSubmit) } + .onChange(of: field) { _, value in + if value == .title { + isContentFocused = false + } + } } } @@ -85,7 +93,10 @@ struct TodoEditorView: View { HStack(spacing: 0) { Button(action: { viewModel.send(.setTabViewTag(.editor)) - field = .content + field = nil + DispatchQueue.main.async { + isContentFocused = true + } }) { Text("편집") .frame(maxWidth: .infinity) @@ -95,8 +106,7 @@ struct TodoEditorView: View { } Divider() Button(action: { - viewModel.send(.setTabViewTag(.preview)) - field = nil + transitionToPreview() }) { Text("미리보기") .frame(maxWidth: .infinity) @@ -115,17 +125,14 @@ struct TodoEditorView: View { if viewModel.state.tabViewTag == .editor { VStack(alignment: .leading, spacing: 8) { markdownHint - TextField( - "", + UIKitTextEditor( text: Binding( get: { viewModel.state.content }, set: { viewModel.send(.setContent($0)) } ), - prompt: Text("설명(선택)").foregroundColor(Color.secondary), - axis: .vertical + isFocused: $isContentFocused, + placeholder: "설명(선택)" ) - .font(.callout) - .focused($field, equals: .content) } } else { if viewModel.state.content.isEmpty { @@ -164,6 +171,15 @@ struct TodoEditorView: View { dismiss() } + private func transitionToPreview() { + field = nil + isContentFocused = false + + DispatchQueue.main.async { + viewModel.send(.setTabViewTag(.preview)) + } + } + private enum Field: Hashable { case title, content } From 1712c66f5513df278cfda9d801844c0545664409 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 15:35:36 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=8F=B0=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=BC=EC=9B=90=ED=99=94=20=EB=B0=8F=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=EB=86=92=EC=9D=B4=EB=A5=BC=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?=ED=8F=B0=ED=8A=B8=EC=9D=98=20lineHeight=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/Component/UIKitTextEditor.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/DevLog/UI/Common/Component/UIKitTextEditor.swift b/DevLog/UI/Common/Component/UIKitTextEditor.swift index f44d102..2d84b26 100644 --- a/DevLog/UI/Common/Component/UIKitTextEditor.swift +++ b/DevLog/UI/Common/Component/UIKitTextEditor.swift @@ -11,8 +11,8 @@ import UIKit struct UIKitTextEditor: View { @Binding var text: String @Binding var isFocused: Bool + @State private var minHeight = TextEditorMetrics.font.lineHeight private let placeholder: String - @State private var minHeight = CGFloat(36) init( text: Binding, @@ -35,6 +35,10 @@ struct UIKitTextEditor: View { } } +fileprivate enum TextEditorMetrics { + static let font = UIFont.preferredFont(forTextStyle: .callout) +} + private struct UIKitTextEditorRepresentable: UIViewRepresentable { @Binding var text: String @Binding var minHeight: CGFloat @@ -60,7 +64,7 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator - textView.font = UIFont.preferredFont(forTextStyle: .callout) + textView.font = TextEditorMetrics.font textView.backgroundColor = .clear textView.textColor = .label textView.tintColor = .tintColor @@ -176,7 +180,7 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { let nextHeight = ceil(textView.sizeThatFits( CGSize(width: width, height: .greatestFiniteMagnitude) ).height) - let resolvedHeight = max(nextHeight, 36) + let resolvedHeight = max(nextHeight, TextEditorMetrics.font.lineHeight) if parent.minHeight != resolvedHeight { DispatchQueue.main.async { From 4f29a80ab0b2c36e3ed20c410621ad0fd5ff213e Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 15:55:42 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20TextEditor=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=EB=A5=BC=20=ED=83=AD=20=ED=96=88=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=EC=9E=90=20=EC=9E=85=EB=A0=A5=20=ED=9B=84=20?= =?UTF-8?q?=ED=8F=AC=EC=BB=A4=EC=8B=B1=EC=9D=B4=20=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/Common/Component/UIKitTextEditor.swift | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/DevLog/UI/Common/Component/UIKitTextEditor.swift b/DevLog/UI/Common/Component/UIKitTextEditor.swift index 2d84b26..2949279 100644 --- a/DevLog/UI/Common/Component/UIKitTextEditor.swift +++ b/DevLog/UI/Common/Component/UIKitTextEditor.swift @@ -10,8 +10,8 @@ import UIKit struct UIKitTextEditor: View { @Binding var text: String - @Binding var isFocused: Bool @State private var minHeight = TextEditorMetrics.font.lineHeight + private let focusBinding: Binding private let placeholder: String init( @@ -20,7 +20,7 @@ struct UIKitTextEditor: View { placeholder: String = "" ) { self._text = text - self._isFocused = isFocused + self.focusBinding = isFocused self.placeholder = placeholder } @@ -28,31 +28,31 @@ struct UIKitTextEditor: View { UIKitTextEditorRepresentable( text: $text, minHeight: $minHeight, - isFocused: isFocused, + focusBinding: focusBinding, placeholder: placeholder ) .frame(maxWidth: .infinity, minHeight: minHeight) } } -fileprivate enum TextEditorMetrics { +private enum TextEditorMetrics { static let font = UIFont.preferredFont(forTextStyle: .callout) } private struct UIKitTextEditorRepresentable: UIViewRepresentable { @Binding var text: String @Binding var minHeight: CGFloat - private let isFocused: Bool + private let focusBinding: Binding private let placeholder: String init( text: Binding, minHeight: Binding, - isFocused: Bool, + focusBinding: Binding, placeholder: String ) { self._text = text - self.isFocused = isFocused + self.focusBinding = focusBinding self._minHeight = minHeight self.placeholder = placeholder } @@ -90,7 +90,7 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { context.coordinator.applyPlaceholderIfNeeded(to: uiView) DispatchQueue.main.async { - if isFocused { + if focusBinding.wrappedValue { if !uiView.isFirstResponder { context.coordinator.preserveAncestorScrollOffset(for: uiView) uiView.becomeFirstResponder() @@ -122,6 +122,10 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { textView.textColor = .label } + if !parent.focusBinding.wrappedValue { + parent.focusBinding.wrappedValue = true + } + restoreAncestorScrollOffsetIfNeeded() DispatchQueue.main.async { [weak self] in @@ -141,6 +145,10 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { } func textViewDidEndEditing(_ textView: UITextView) { + if parent.focusBinding.wrappedValue { + parent.focusBinding.wrappedValue = false + } + applyPlaceholderIfNeeded(to: textView) } From 45eb3355a27538532fce841e4bd7946172fe73b1 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 16:03:58 +0900 Subject: [PATCH 4/7] =?UTF-8?q?ui:=20=EA=B8=B0=EB=B3=B8=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=20body=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/Component/UIKitTextEditor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/UI/Common/Component/UIKitTextEditor.swift b/DevLog/UI/Common/Component/UIKitTextEditor.swift index 2949279..6100848 100644 --- a/DevLog/UI/Common/Component/UIKitTextEditor.swift +++ b/DevLog/UI/Common/Component/UIKitTextEditor.swift @@ -36,7 +36,7 @@ struct UIKitTextEditor: View { } private enum TextEditorMetrics { - static let font = UIFont.preferredFont(forTextStyle: .callout) + static let font = UIFont.preferredFont(forTextStyle: .body) } private struct UIKitTextEditorRepresentable: UIViewRepresentable { From 952d7b125d9487dc875241a8ca17ea84e5541a0b Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 16:38:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20.focused()=20=EB=AA=A8=EB=94=94?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=EC=96=B4=EB=A1=9C=20=ED=8F=AC=EC=BB=A4?= =?UTF-8?q?=EC=8B=B1=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/Common/Component/UIKitTextEditor.swift | 101 +++++++++++++++--- DevLog/UI/Home/TodoEditorView.swift | 16 +-- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/DevLog/UI/Common/Component/UIKitTextEditor.swift b/DevLog/UI/Common/Component/UIKitTextEditor.swift index 6100848..838253d 100644 --- a/DevLog/UI/Common/Component/UIKitTextEditor.swift +++ b/DevLog/UI/Common/Component/UIKitTextEditor.swift @@ -10,17 +10,15 @@ import UIKit struct UIKitTextEditor: View { @Binding var text: String + @Environment(\.uiKitTextEditorFocusBinding) private var focusBinding @State private var minHeight = TextEditorMetrics.font.lineHeight - private let focusBinding: Binding private let placeholder: String init( text: Binding, - isFocused: Binding, placeholder: String = "" ) { self._text = text - self.focusBinding = isFocused self.placeholder = placeholder } @@ -33,22 +31,66 @@ struct UIKitTextEditor: View { ) .frame(maxWidth: .infinity, minHeight: minHeight) } + + // 각 메서드 내에 있는 `.focused()`의 정체 + // 해당 .focused()는 SwiftUI의 모디파이어 + // 이 뷰를 SwiftUI 포커스 시스템에 실제 포커스 타겟으로 등록해주는 역할을 함 + + func focused(_ condition: FocusState.Binding) -> some View { + modifier(TextEditorFocusModifier( + focusBinding: Binding(condition) + )) + .focused(condition) + } + + 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) + } } 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) + } +} + +private struct TextEditorFocusBindingKey: EnvironmentKey { + static let defaultValue: Binding? = nil +} + +private extension EnvironmentValues { + var uiKitTextEditorFocusBinding: Binding? { + get { self[TextEditorFocusBindingKey.self] } + set { self[TextEditorFocusBindingKey.self] = newValue } + } +} + private struct UIKitTextEditorRepresentable: UIViewRepresentable { @Binding var text: String @Binding var minHeight: CGFloat - private let focusBinding: Binding + private let focusBinding: Binding? private let placeholder: String init( text: Binding, minHeight: Binding, - focusBinding: Binding, + focusBinding: Binding?, placeholder: String ) { self._text = text @@ -90,13 +132,15 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { context.coordinator.applyPlaceholderIfNeeded(to: uiView) DispatchQueue.main.async { - if focusBinding.wrappedValue { - if !uiView.isFirstResponder { - context.coordinator.preserveAncestorScrollOffset(for: uiView) - uiView.becomeFirstResponder() + if let focusBinding { + if focusBinding.wrappedValue { + if !uiView.isFirstResponder { + context.coordinator.preserveAncestorScrollOffset(for: uiView) + uiView.becomeFirstResponder() + } + } else if uiView.isFirstResponder { + uiView.resignFirstResponder() } - } else if uiView.isFirstResponder { - uiView.resignFirstResponder() } context.coordinator.updateHeight(for: uiView) } @@ -122,8 +166,8 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { textView.textColor = .label } - if !parent.focusBinding.wrappedValue { - parent.focusBinding.wrappedValue = true + if let focusBinding = parent.focusBinding, !focusBinding.wrappedValue { + focusBinding.wrappedValue = true } restoreAncestorScrollOffsetIfNeeded() @@ -145,8 +189,8 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { } func textViewDidEndEditing(_ textView: UITextView) { - if parent.focusBinding.wrappedValue { - parent.focusBinding.wrappedValue = false + if let focusBinding = parent.focusBinding, focusBinding.wrappedValue { + focusBinding.wrappedValue = false } applyPlaceholderIfNeeded(to: textView) @@ -199,6 +243,33 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { } } +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 + } + } + ) + } +} + private extension UIView { var enclosingScrollView: UIScrollView? { var currentSuperview = superview diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index 1c975c4..5dc88ee 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -15,7 +15,6 @@ struct TodoEditorView: View { @FocusState private var field: Field? private let calendar = Calendar.current var onSubmit: ((Todo) -> Void)? - @State private var isContentFocused = false var body: some View { NavigationStack { @@ -37,8 +36,6 @@ struct TodoEditorView: View { } .onTapGesture { field = .content - // 나중에 제거 - isContentFocused = true } .navigationTitle(viewModel.navigationTitle) .navigationBarTitleDisplayMode(.inline) @@ -65,11 +62,6 @@ struct TodoEditorView: View { } .disabled(!viewModel.isReadyToSubmit) } - .onChange(of: field) { _, value in - if value == .title { - isContentFocused = false - } - } } } @@ -93,10 +85,7 @@ struct TodoEditorView: View { HStack(spacing: 0) { Button(action: { viewModel.send(.setTabViewTag(.editor)) - field = nil - DispatchQueue.main.async { - isContentFocused = true - } + field = .content }) { Text("편집") .frame(maxWidth: .infinity) @@ -130,9 +119,9 @@ struct TodoEditorView: View { get: { viewModel.state.content }, set: { viewModel.send(.setContent($0)) } ), - isFocused: $isContentFocused, placeholder: "설명(선택)" ) + .focused($field, equals: .content) } } else { if viewModel.state.content.isEmpty { @@ -173,7 +162,6 @@ struct TodoEditorView: View { private func transitionToPreview() { field = nil - isContentFocused = false DispatchQueue.main.async { viewModel.send(.setTabViewTag(.preview)) From b5eae9fb4ba92821aaf8ca4f46acf2331fd76dd9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 16:50:16 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20Main=20actor-isolated=20property=20'?= =?UTF-8?q?logger'=20can=20not=20be=20referenced=20from=20a=20Sendable=20c?= =?UTF-8?q?losure=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/MainViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DevLog/Presentation/ViewModel/MainViewModel.swift b/DevLog/Presentation/ViewModel/MainViewModel.swift index 25b786b..ce779f5 100644 --- a/DevLog/Presentation/ViewModel/MainViewModel.swift +++ b/DevLog/Presentation/ViewModel/MainViewModel.swift @@ -108,7 +108,9 @@ private extension MainViewModel { func updateBadgeCount(_ count: Int) { UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in if let error { - self?.logger.error("Failed to update application badge count", error: error) + Task { @MainActor in + self?.logger.error("Failed to update application badge count", error: error) + } } } } From 9f94bb6509496059ac8d62ba19f40814f7056647 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 18 Mar 2026 17:35:39 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20DispatchQueue=EB=B3=B4=EB=8B=A4?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=EC=A0=81=EC=9C=BC=EB=A1=9C=20KVO=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=EC=9D=84=20=EC=B1=84=ED=83=9D=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/Common/Component/UIKitTextEditor.swift | 81 ++++++++++++++----- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/DevLog/UI/Common/Component/UIKitTextEditor.swift b/DevLog/UI/Common/Component/UIKitTextEditor.swift index 838253d..cb7bffd 100644 --- a/DevLog/UI/Common/Component/UIKitTextEditor.swift +++ b/DevLog/UI/Common/Component/UIKitTextEditor.swift @@ -135,7 +135,7 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { if let focusBinding { if focusBinding.wrappedValue { if !uiView.isFirstResponder { - context.coordinator.preserveAncestorScrollOffset(for: uiView) + context.coordinator.startTrackingOffset(for: uiView) uiView.becomeFirstResponder() } } else if uiView.isFirstResponder { @@ -148,15 +148,17 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { final class Coordinator: NSObject, UITextViewDelegate { var parent: UIKitTextEditorRepresentable - private weak var ancestorScrollView: UIScrollView? - private var preservedContentOffset: CGPoint? + private weak var scrollView: UIScrollView? + private var offsetObservation: NSKeyValueObservation? + private var trackedOffset: CGPoint? + private var isRestoringOffset = false init(_ parent: UIKitTextEditorRepresentable) { self.parent = parent } func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { - preserveAncestorScrollOffset(for: textView) + startTrackingOffset(for: textView) return true } @@ -170,20 +172,16 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { focusBinding.wrappedValue = true } - restoreAncestorScrollOffsetIfNeeded() + restoreOffsetIfNeeded() DispatchQueue.main.async { [weak self] in - self?.restoreAncestorScrollOffsetIfNeeded() + self?.restoreOffsetIfNeeded() self?.updateHeight(for: textView) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.restoreAncestorScrollOffsetIfNeeded() - self?.preservedContentOffset = nil - } } func textViewDidChange(_ textView: UITextView) { + stopTrackingOffset() parent.text = textView.text updateHeight(for: textView) } @@ -193,6 +191,7 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { focusBinding.wrappedValue = false } + stopTrackingOffset() applyPlaceholderIfNeeded(to: textView) } @@ -210,19 +209,65 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable { textView.textColor == .placeholderText } - func preserveAncestorScrollOffset(for textView: UITextView) { - ancestorScrollView = textView.enclosingScrollView - preservedContentOffset = ancestorScrollView?.contentOffset + func startTrackingOffset(for textView: UITextView) { + stopObservingOffset() + scrollView = textView.enclosingScrollView + trackedOffset = scrollView?.contentOffset + observeOffsetIfNeeded() + } + + func restoreOffsetIfNeeded() { + guard let scrollView, let trackedOffset else { return } + + if scrollView.contentOffset != trackedOffset { + isRestoringOffset = true + scrollView.setContentOffset(trackedOffset, animated: false) + isRestoringOffset = false + } } - func restoreAncestorScrollOffsetIfNeeded() { - guard let ancestorScrollView, let preservedContentOffset else { return } + func observeOffsetIfNeeded() { + guard let scrollView else { return } - if ancestorScrollView.contentOffset != preservedContentOffset { - ancestorScrollView.setContentOffset(preservedContentOffset, animated: false) + offsetObservation = scrollView.observe( + \.contentOffset, + options: [.new] + ) { [weak self] scrollView, _ in + self?.handleOffsetChange(in: scrollView) } } + func handleOffsetChange(in scrollView: UIScrollView) { + guard let trackedOffset else { + stopObservingOffset() + return + } + + if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating { + stopTrackingOffset() + return + } + + if isRestoringOffset { + return + } + + if scrollView.contentOffset != trackedOffset { + restoreOffsetIfNeeded() + } + } + + func stopObservingOffset() { + offsetObservation?.invalidate() + offsetObservation = nil + } + + func stopTrackingOffset() { + stopObservingOffset() + scrollView = nil + trackedOffset = nil + } + func updateHeight(for textView: UITextView) { textView.layoutIfNeeded()