Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DevLog/Presentation/ViewModel/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
3 changes: 0 additions & 3 deletions DevLog/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,6 @@
},
"생성일" : {

},
"설명(선택)" : {

},
"설정" : {

Expand Down
287 changes: 287 additions & 0 deletions DevLog/UI/Common/Component/UIKitTextEditor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
//
// UIKitTextEditor.swift
// DevLog
//
// Created by opfic on 3/18/26.
//

import SwiftUI
import UIKit

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<String>,
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<Bool>.Binding) -> some View {
modifier(TextEditorFocusModifier(
focusBinding: Binding(condition)
))
.focused(condition)
}

func focused<Value>(
_ binding: FocusState<Value>.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<Bool>

func body(content: Content) -> some View {
content
.environment(\.uiKitTextEditorFocusBinding, focusBinding)
}
}

private struct TextEditorFocusBindingKey: EnvironmentKey {
static let defaultValue: Binding<Bool>? = nil
}

private extension EnvironmentValues {
var uiKitTextEditorFocusBinding: Binding<Bool>? {
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<Bool>?
private let placeholder: String

init(
text: Binding<String>,
minHeight: Binding<CGFloat>,
focusBinding: Binding<Bool>?,
placeholder: String
) {
self._text = text
self.focusBinding = focusBinding
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 = 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
}

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 let focusBinding {
if focusBinding.wrappedValue {
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
}

if let focusBinding = parent.focusBinding, !focusBinding.wrappedValue {
focusBinding.wrappedValue = true
}

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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

DispatchQueue.main.asyncAfter를 사용하여 0.1초의 고정된 지연 시간을 주는 것은 불안정할 수 있습니다. 디바이스의 성능이나 시스템 상태에 따라 이 시간이 충분하지 않아 버그가 발생할 수 있습니다.
더 안정적인 방법으로 UIResponder.keyboardWillShowNotification 또는 UIResponder.keyboardDidShowNotification과 같은 키보드 노티피케이션을 구독하여 키보드 애니메이션과 동기화하거나, UIScrollViewcontentOffset을 KVO로 관찰하여 변경에 대응하는 것을 고려해 보시는 것이 좋겠습니다.

}

func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
updateHeight(for: textView)
}

func textViewDidEndEditing(_ textView: UITextView) {
if let focusBinding = parent.focusBinding, focusBinding.wrappedValue {
focusBinding.wrappedValue = false
}

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, TextEditorMetrics.font.lineHeight)

if parent.minHeight != resolvedHeight {
DispatchQueue.main.async {
self.parent.minHeight = resolvedHeight
}
}
}
}
}

private extension Binding where Value == Bool {
init(_ binding: FocusState<Bool>.Binding) {
self.init(
get: { binding.wrappedValue },
set: { binding.wrappedValue = $0 }
)
}

init<FocusedValue>(
_ binding: FocusState<FocusedValue>.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

while let view = currentSuperview {
if let scrollView = view as? UIScrollView {
return scrollView
}

currentSuperview = view.superview
}

return nil
}
}
18 changes: 11 additions & 7 deletions DevLog/UI/Home/TodoEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ struct TodoEditorView: View {
}
Divider()
Button(action: {
viewModel.send(.setTabViewTag(.preview))
field = nil
transitionToPreview()
}) {
Text("미리보기")
.frame(maxWidth: .infinity)
Expand All @@ -115,16 +114,13 @@ 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
placeholder: "설명(선택)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

placeholder에 문자열을 직접 하드코딩하면 국제화가 지원되지 않는 문제가 있습니다. 기존 TextFieldprompt에서는 Text 뷰를 통해 자동적으로 지역화가 이루어졌습니다.
Localizable.xcstrings 파일에 "설명(선택)" 키를 다시 추가하고, 이 곳에서는 String(localized: "설명(선택)") 또는 NSLocalizedString를 사용하여 지역화된 문자열을 사용하도록 수정하는 것이 좋겠습니다.

Suggested change
placeholder: "설명(선택)"
placeholder: String(localized: "설명(선택)")

)
.font(.callout)
.focused($field, equals: .content)
}
} else {
Expand Down Expand Up @@ -164,6 +160,14 @@ struct TodoEditorView: View {
dismiss()
}

private func transitionToPreview() {
field = nil

DispatchQueue.main.async {
viewModel.send(.setTabViewTag(.preview))
}
}

private enum Field: Hashable {
case title, content
}
Expand Down