Skip to content

Commit 952d7b1

Browse files
committed
refactor: .focused() 모디파이어로 포커싱 제어
1 parent 45eb335 commit 952d7b1

2 files changed

Lines changed: 88 additions & 29 deletions

File tree

DevLog/UI/Common/Component/UIKitTextEditor.swift

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,15 @@ import UIKit
1010

1111
struct UIKitTextEditor: View {
1212
@Binding var text: String
13+
@Environment(\.uiKitTextEditorFocusBinding) private var focusBinding
1314
@State private var minHeight = TextEditorMetrics.font.lineHeight
14-
private let focusBinding: Binding<Bool>
1515
private let placeholder: String
1616

1717
init(
1818
text: Binding<String>,
19-
isFocused: Binding<Bool>,
2019
placeholder: String = ""
2120
) {
2221
self._text = text
23-
self.focusBinding = isFocused
2422
self.placeholder = placeholder
2523
}
2624

@@ -33,22 +31,66 @@ struct UIKitTextEditor: View {
3331
)
3432
.frame(maxWidth: .infinity, minHeight: minHeight)
3533
}
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+
}
3658
}
3759

3860
private enum TextEditorMetrics {
3961
static let font = UIFont.preferredFont(forTextStyle: .body)
4062
}
4163

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+
4284
private struct UIKitTextEditorRepresentable: UIViewRepresentable {
4385
@Binding var text: String
4486
@Binding var minHeight: CGFloat
45-
private let focusBinding: Binding<Bool>
87+
private let focusBinding: Binding<Bool>?
4688
private let placeholder: String
4789

4890
init(
4991
text: Binding<String>,
5092
minHeight: Binding<CGFloat>,
51-
focusBinding: Binding<Bool>,
93+
focusBinding: Binding<Bool>?,
5294
placeholder: String
5395
) {
5496
self._text = text
@@ -90,13 +132,15 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable {
90132
context.coordinator.applyPlaceholderIfNeeded(to: uiView)
91133

92134
DispatchQueue.main.async {
93-
if focusBinding.wrappedValue {
94-
if !uiView.isFirstResponder {
95-
context.coordinator.preserveAncestorScrollOffset(for: uiView)
96-
uiView.becomeFirstResponder()
135+
if let focusBinding {
136+
if focusBinding.wrappedValue {
137+
if !uiView.isFirstResponder {
138+
context.coordinator.preserveAncestorScrollOffset(for: uiView)
139+
uiView.becomeFirstResponder()
140+
}
141+
} else if uiView.isFirstResponder {
142+
uiView.resignFirstResponder()
97143
}
98-
} else if uiView.isFirstResponder {
99-
uiView.resignFirstResponder()
100144
}
101145
context.coordinator.updateHeight(for: uiView)
102146
}
@@ -122,8 +166,8 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable {
122166
textView.textColor = .label
123167
}
124168

125-
if !parent.focusBinding.wrappedValue {
126-
parent.focusBinding.wrappedValue = true
169+
if let focusBinding = parent.focusBinding, !focusBinding.wrappedValue {
170+
focusBinding.wrappedValue = true
127171
}
128172

129173
restoreAncestorScrollOffsetIfNeeded()
@@ -145,8 +189,8 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable {
145189
}
146190

147191
func textViewDidEndEditing(_ textView: UITextView) {
148-
if parent.focusBinding.wrappedValue {
149-
parent.focusBinding.wrappedValue = false
192+
if let focusBinding = parent.focusBinding, focusBinding.wrappedValue {
193+
focusBinding.wrappedValue = false
150194
}
151195

152196
applyPlaceholderIfNeeded(to: textView)
@@ -199,6 +243,33 @@ private struct UIKitTextEditorRepresentable: UIViewRepresentable {
199243
}
200244
}
201245

246+
private extension Binding where Value == Bool {
247+
init(_ binding: FocusState<Bool>.Binding) {
248+
self.init(
249+
get: { binding.wrappedValue },
250+
set: { binding.wrappedValue = $0 }
251+
)
252+
}
253+
254+
init<FocusedValue>(
255+
_ binding: FocusState<FocusedValue>.Binding,
256+
equals value: FocusedValue
257+
) where FocusedValue: Hashable & ExpressibleByNilLiteral {
258+
self.init(
259+
get: {
260+
binding.wrappedValue == value
261+
},
262+
set: { isFocused in
263+
if isFocused {
264+
binding.wrappedValue = value
265+
} else if binding.wrappedValue == value {
266+
binding.wrappedValue = nil
267+
}
268+
}
269+
)
270+
}
271+
}
272+
202273
private extension UIView {
203274
var enclosingScrollView: UIScrollView? {
204275
var currentSuperview = superview

DevLog/UI/Home/TodoEditorView.swift

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ struct TodoEditorView: View {
1515
@FocusState private var field: Field?
1616
private let calendar = Calendar.current
1717
var onSubmit: ((Todo) -> Void)?
18-
@State private var isContentFocused = false
1918

2019
var body: some View {
2120
NavigationStack {
@@ -37,8 +36,6 @@ struct TodoEditorView: View {
3736
}
3837
.onTapGesture {
3938
field = .content
40-
// 나중에 제거
41-
isContentFocused = true
4239
}
4340
.navigationTitle(viewModel.navigationTitle)
4441
.navigationBarTitleDisplayMode(.inline)
@@ -65,11 +62,6 @@ struct TodoEditorView: View {
6562
}
6663
.disabled(!viewModel.isReadyToSubmit)
6764
}
68-
.onChange(of: field) { _, value in
69-
if value == .title {
70-
isContentFocused = false
71-
}
72-
}
7365
}
7466
}
7567

@@ -93,10 +85,7 @@ struct TodoEditorView: View {
9385
HStack(spacing: 0) {
9486
Button(action: {
9587
viewModel.send(.setTabViewTag(.editor))
96-
field = nil
97-
DispatchQueue.main.async {
98-
isContentFocused = true
99-
}
88+
field = .content
10089
}) {
10190
Text("편집")
10291
.frame(maxWidth: .infinity)
@@ -130,9 +119,9 @@ struct TodoEditorView: View {
130119
get: { viewModel.state.content },
131120
set: { viewModel.send(.setContent($0)) }
132121
),
133-
isFocused: $isContentFocused,
134122
placeholder: "설명(선택)"
135123
)
124+
.focused($field, equals: .content)
136125
}
137126
} else {
138127
if viewModel.state.content.isEmpty {
@@ -173,7 +162,6 @@ struct TodoEditorView: View {
173162

174163
private func transitionToPreview() {
175164
field = nil
176-
isContentFocused = false
177165

178166
DispatchQueue.main.async {
179167
viewModel.send(.setTabViewTag(.preview))

0 commit comments

Comments
 (0)