Skip to content

Commit a658eeb

Browse files
authored
[#292] TodoEditorView에서 content의 상단 부분을 수정하려고 시도하면 키보드가 내려간 상태에서 누르면 무조건 아래쪽으로 내려가는 이슈를 해결한다 (#298)
* feat: UIKitTextEditor 구현 및 사용 (데모) * feat: 폰트 관리 일원화 및 최소 높이를 해당 폰트의 lineHeight로 조정 * fix: TextEditor 자체를 탭 했을 때 한글자 입력 후 포커싱이 해제되는 현상 해결 * ui: 기본 폰트 body로 수정 * refactor: .focused() 모디파이어로 포커싱 제어 * fix: Main actor-isolated property 'logger' can not be referenced from a Sendable closure 해결 * refactor: DispatchQueue보다 안정적으로 KVO 패턴을 채택하여 개선
1 parent d4384da commit a658eeb

4 files changed

Lines changed: 346 additions & 11 deletions

File tree

DevLog/Presentation/ViewModel/MainViewModel.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ private extension MainViewModel {
108108
func updateBadgeCount(_ count: Int) {
109109
UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in
110110
if let error {
111-
self?.logger.error("Failed to update application badge count", error: error)
111+
Task { @MainActor in
112+
self?.logger.error("Failed to update application badge count", error: error)
113+
}
112114
}
113115
}
114116
}

DevLog/Resource/Localizable.xcstrings

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,6 @@
323323
},
324324
"생성일" : {
325325

326-
},
327-
"설명(선택)" : {
328-
329326
},
330327
"설정" : {
331328

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
//
2+
// UIKitTextEditor.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 3/18/26.
6+
//
7+
8+
import SwiftUI
9+
import UIKit
10+
11+
struct UIKitTextEditor: View {
12+
@Binding var text: String
13+
@Environment(\.uiKitTextEditorFocusBinding) private var focusBinding
14+
@State private var minHeight = TextEditorMetrics.font.lineHeight
15+
private let placeholder: String
16+
17+
init(
18+
text: Binding<String>,
19+
placeholder: String = ""
20+
) {
21+
self._text = text
22+
self.placeholder = placeholder
23+
}
24+
25+
var body: some View {
26+
UIKitTextEditorRepresentable(
27+
text: $text,
28+
minHeight: $minHeight,
29+
focusBinding: focusBinding,
30+
placeholder: placeholder
31+
)
32+
.frame(maxWidth: .infinity, minHeight: minHeight)
33+
}
34+
35+
// 각 메서드 내에 있는 `.focused()`의 정체
36+
// 해당 .focused()는 SwiftUI의 모디파이어
37+
// 이 뷰를 SwiftUI 포커스 시스템에 실제 포커스 타겟으로 등록해주는 역할을 함
38+
39+
func focused(_ condition: FocusState<Bool>.Binding) -> some View {
40+
modifier(TextEditorFocusModifier(
41+
focusBinding: Binding(condition)
42+
))
43+
.focused(condition)
44+
}
45+
46+
func focused<Value>(
47+
_ binding: FocusState<Value>.Binding,
48+
equals value: Value
49+
) -> some View where Value: Hashable & ExpressibleByNilLiteral {
50+
modifier(TextEditorFocusModifier(
51+
focusBinding: Binding(
52+
binding,
53+
equals: value
54+
)
55+
))
56+
.focused(binding, equals: value)
57+
}
58+
}
59+
60+
private enum TextEditorMetrics {
61+
static let font = UIFont.preferredFont(forTextStyle: .body)
62+
}
63+
64+
private struct TextEditorFocusModifier: ViewModifier {
65+
let focusBinding: Binding<Bool>
66+
67+
func body(content: Content) -> some View {
68+
content
69+
.environment(\.uiKitTextEditorFocusBinding, focusBinding)
70+
}
71+
}
72+
73+
private struct TextEditorFocusBindingKey: EnvironmentKey {
74+
static let defaultValue: Binding<Bool>? = nil
75+
}
76+
77+
private extension EnvironmentValues {
78+
var uiKitTextEditorFocusBinding: Binding<Bool>? {
79+
get { self[TextEditorFocusBindingKey.self] }
80+
set { self[TextEditorFocusBindingKey.self] = newValue }
81+
}
82+
}
83+
84+
private struct UIKitTextEditorRepresentable: UIViewRepresentable {
85+
@Binding var text: String
86+
@Binding var minHeight: CGFloat
87+
private let focusBinding: Binding<Bool>?
88+
private let placeholder: String
89+
90+
init(
91+
text: Binding<String>,
92+
minHeight: Binding<CGFloat>,
93+
focusBinding: Binding<Bool>?,
94+
placeholder: String
95+
) {
96+
self._text = text
97+
self.focusBinding = focusBinding
98+
self._minHeight = minHeight
99+
self.placeholder = placeholder
100+
}
101+
102+
func makeCoordinator() -> Coordinator {
103+
Coordinator(self)
104+
}
105+
106+
func makeUIView(context: Context) -> UITextView {
107+
let textView = UITextView()
108+
textView.delegate = context.coordinator
109+
textView.font = TextEditorMetrics.font
110+
textView.backgroundColor = .clear
111+
textView.textColor = .label
112+
textView.tintColor = .tintColor
113+
textView.textContainer.lineFragmentPadding = 0
114+
textView.textContainer.widthTracksTextView = true
115+
textView.textContainer.lineBreakMode = .byWordWrapping
116+
textView.textContainerInset = .zero
117+
textView.isScrollEnabled = false
118+
textView.autocorrectionType = .no
119+
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
120+
textView.setContentHuggingPriority(.defaultLow, for: .horizontal)
121+
context.coordinator.applyPlaceholderIfNeeded(to: textView)
122+
return textView
123+
}
124+
125+
func updateUIView(_ uiView: UITextView, context: Context) {
126+
context.coordinator.parent = self
127+
128+
if !context.coordinator.isShowingPlaceholder(in: uiView) && uiView.text != text {
129+
uiView.text = text
130+
}
131+
132+
context.coordinator.applyPlaceholderIfNeeded(to: uiView)
133+
134+
DispatchQueue.main.async {
135+
if let focusBinding {
136+
if focusBinding.wrappedValue {
137+
if !uiView.isFirstResponder {
138+
context.coordinator.startTrackingOffset(for: uiView)
139+
uiView.becomeFirstResponder()
140+
}
141+
} else if uiView.isFirstResponder {
142+
uiView.resignFirstResponder()
143+
}
144+
}
145+
context.coordinator.updateHeight(for: uiView)
146+
}
147+
}
148+
149+
final class Coordinator: NSObject, UITextViewDelegate {
150+
var parent: UIKitTextEditorRepresentable
151+
private weak var scrollView: UIScrollView?
152+
private var offsetObservation: NSKeyValueObservation?
153+
private var trackedOffset: CGPoint?
154+
private var isRestoringOffset = false
155+
156+
init(_ parent: UIKitTextEditorRepresentable) {
157+
self.parent = parent
158+
}
159+
160+
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
161+
startTrackingOffset(for: textView)
162+
return true
163+
}
164+
165+
func textViewDidBeginEditing(_ textView: UITextView) {
166+
if isShowingPlaceholder(in: textView) {
167+
textView.text = nil
168+
textView.textColor = .label
169+
}
170+
171+
if let focusBinding = parent.focusBinding, !focusBinding.wrappedValue {
172+
focusBinding.wrappedValue = true
173+
}
174+
175+
restoreOffsetIfNeeded()
176+
177+
DispatchQueue.main.async { [weak self] in
178+
self?.restoreOffsetIfNeeded()
179+
self?.updateHeight(for: textView)
180+
}
181+
}
182+
183+
func textViewDidChange(_ textView: UITextView) {
184+
stopTrackingOffset()
185+
parent.text = textView.text
186+
updateHeight(for: textView)
187+
}
188+
189+
func textViewDidEndEditing(_ textView: UITextView) {
190+
if let focusBinding = parent.focusBinding, focusBinding.wrappedValue {
191+
focusBinding.wrappedValue = false
192+
}
193+
194+
stopTrackingOffset()
195+
applyPlaceholderIfNeeded(to: textView)
196+
}
197+
198+
func applyPlaceholderIfNeeded(to textView: UITextView) {
199+
if parent.text.isEmpty && !textView.isFirstResponder {
200+
textView.text = parent.placeholder
201+
textView.textColor = .placeholderText
202+
} else if isShowingPlaceholder(in: textView) {
203+
textView.text = parent.text
204+
textView.textColor = .label
205+
}
206+
}
207+
208+
func isShowingPlaceholder(in textView: UITextView) -> Bool {
209+
textView.textColor == .placeholderText
210+
}
211+
212+
func startTrackingOffset(for textView: UITextView) {
213+
stopObservingOffset()
214+
scrollView = textView.enclosingScrollView
215+
trackedOffset = scrollView?.contentOffset
216+
observeOffsetIfNeeded()
217+
}
218+
219+
func restoreOffsetIfNeeded() {
220+
guard let scrollView, let trackedOffset else { return }
221+
222+
if scrollView.contentOffset != trackedOffset {
223+
isRestoringOffset = true
224+
scrollView.setContentOffset(trackedOffset, animated: false)
225+
isRestoringOffset = false
226+
}
227+
}
228+
229+
func observeOffsetIfNeeded() {
230+
guard let scrollView else { return }
231+
232+
offsetObservation = scrollView.observe(
233+
\.contentOffset,
234+
options: [.new]
235+
) { [weak self] scrollView, _ in
236+
self?.handleOffsetChange(in: scrollView)
237+
}
238+
}
239+
240+
func handleOffsetChange(in scrollView: UIScrollView) {
241+
guard let trackedOffset else {
242+
stopObservingOffset()
243+
return
244+
}
245+
246+
if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating {
247+
stopTrackingOffset()
248+
return
249+
}
250+
251+
if isRestoringOffset {
252+
return
253+
}
254+
255+
if scrollView.contentOffset != trackedOffset {
256+
restoreOffsetIfNeeded()
257+
}
258+
}
259+
260+
func stopObservingOffset() {
261+
offsetObservation?.invalidate()
262+
offsetObservation = nil
263+
}
264+
265+
func stopTrackingOffset() {
266+
stopObservingOffset()
267+
scrollView = nil
268+
trackedOffset = nil
269+
}
270+
271+
func updateHeight(for textView: UITextView) {
272+
textView.layoutIfNeeded()
273+
274+
let width = textView.bounds.width
275+
guard 0 < width else { return }
276+
277+
let nextHeight = ceil(textView.sizeThatFits(
278+
CGSize(width: width, height: .greatestFiniteMagnitude)
279+
).height)
280+
let resolvedHeight = max(nextHeight, TextEditorMetrics.font.lineHeight)
281+
282+
if parent.minHeight != resolvedHeight {
283+
DispatchQueue.main.async {
284+
self.parent.minHeight = resolvedHeight
285+
}
286+
}
287+
}
288+
}
289+
}
290+
291+
private extension Binding where Value == Bool {
292+
init(_ binding: FocusState<Bool>.Binding) {
293+
self.init(
294+
get: { binding.wrappedValue },
295+
set: { binding.wrappedValue = $0 }
296+
)
297+
}
298+
299+
init<FocusedValue>(
300+
_ binding: FocusState<FocusedValue>.Binding,
301+
equals value: FocusedValue
302+
) where FocusedValue: Hashable & ExpressibleByNilLiteral {
303+
self.init(
304+
get: {
305+
binding.wrappedValue == value
306+
},
307+
set: { isFocused in
308+
if isFocused {
309+
binding.wrappedValue = value
310+
} else if binding.wrappedValue == value {
311+
binding.wrappedValue = nil
312+
}
313+
}
314+
)
315+
}
316+
}
317+
318+
private extension UIView {
319+
var enclosingScrollView: UIScrollView? {
320+
var currentSuperview = superview
321+
322+
while let view = currentSuperview {
323+
if let scrollView = view as? UIScrollView {
324+
return scrollView
325+
}
326+
327+
currentSuperview = view.superview
328+
}
329+
330+
return nil
331+
}
332+
}

DevLog/UI/Home/TodoEditorView.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,7 @@ struct TodoEditorView: View {
9595
}
9696
Divider()
9797
Button(action: {
98-
viewModel.send(.setTabViewTag(.preview))
99-
field = nil
98+
transitionToPreview()
10099
}) {
101100
Text("미리보기")
102101
.frame(maxWidth: .infinity)
@@ -115,16 +114,13 @@ struct TodoEditorView: View {
115114
if viewModel.state.tabViewTag == .editor {
116115
VStack(alignment: .leading, spacing: 8) {
117116
markdownHint
118-
TextField(
119-
"",
117+
UIKitTextEditor(
120118
text: Binding(
121119
get: { viewModel.state.content },
122120
set: { viewModel.send(.setContent($0)) }
123121
),
124-
prompt: Text("설명(선택)").foregroundColor(Color.secondary),
125-
axis: .vertical
122+
placeholder: "설명(선택)"
126123
)
127-
.font(.callout)
128124
.focused($field, equals: .content)
129125
}
130126
} else {
@@ -164,6 +160,14 @@ struct TodoEditorView: View {
164160
dismiss()
165161
}
166162

163+
private func transitionToPreview() {
164+
field = nil
165+
166+
DispatchQueue.main.async {
167+
viewModel.send(.setTabViewTag(.preview))
168+
}
169+
}
170+
167171
private enum Field: Hashable {
168172
case title, content
169173
}

0 commit comments

Comments
 (0)