Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public struct GoalCreateView: View {
NavigationStack {
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
GoalCreateTitleField(store: store, focusedField: _focusedField)
GoalCreateDescriptionEditor(store: store, focusedField: _focusedField)
GoalCreateTitleField(store: store, focusedField: $focusedField)
GoalCreateDescriptionEditor(store: store, focusedField: $focusedField)
GoalCreateStatusField()
GoalCreateTodoField(store: store)
}
Expand Down Expand Up @@ -109,7 +109,7 @@ private struct GoalCreateTopBar: View {

private struct GoalCreateTitleField: View {
@Bindable var store: StoreOf<GoalCreateFeature>
@FocusState var focusedField: GoalCreateField?
let focusedField: FocusState<GoalCreateField?>.Binding

var body: some View {
VStack(alignment: .leading, spacing: 14) {
Expand All @@ -120,7 +120,7 @@ private struct GoalCreateTitleField: View {
RecordPresentation.text("development_goal_create_title_placeholder"),
text: $store.title
)
.focused($focusedField, equals: .title)
.focused(focusedField, equals: .title)
.font(.body)
.padding()
.background(Color.surface, in: .rect(cornerRadius: 24))
Expand All @@ -131,24 +131,46 @@ private struct GoalCreateTitleField: View {

private struct GoalCreateDescriptionEditor: View {
@Bindable var store: StoreOf<GoalCreateFeature>
@FocusState var focusedField: GoalCreateField?
let focusedField: FocusState<GoalCreateField?>.Binding

var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(RecordPresentation.text("development_goal_create_description_label"))
.font(.headline)

VStack(spacing: 16) {
GoalCreateModePicker(store: store, focusedField: _focusedField)
GoalCreateModePicker(store: store, focusedField: focusedField)

Group {
switch store.selectedTab {
case .write:
TextEditor(text: $store.markdownContent)
.focused($focusedField, equals: .content)
.font(.body)
.scrollContentBackground(.hidden)
.padding(12)
TextEditorContentLayout(minimumHeight: 120 - 24) {
Text(RecordPresentation.text("development_goal_create_markdown_hint"))
.font(.caption)
.foregroundStyle(Color.textTertiary)
UIKitTextEditor()
.composable(
update: { textEditor in
textEditor.updateInput(
text: $store.markdownContent,
isFocused: focusedField.wrappedValue == .content,
onFocusChange: { isFocused in
if isFocused {
focusedField.wrappedValue = .content
} else if focusedField.wrappedValue == .content {
focusedField.wrappedValue = nil
}
},
isEnabled: !store.isSaving
)
},
sizeThatFits: { UIKitTextEditor.fittingSize(proposal: $0, textEditor: $1) }
)
.focused(focusedField, equals: .content)
}
.padding(12)
.contentShape(.rect)
.onTapGesture { focusedField.wrappedValue = .content }
case .preview:
Group {
if store.markdownContent.isEmpty {
Expand All @@ -167,13 +189,8 @@ private struct GoalCreateDescriptionEditor: View {
}
}
}
.frame(minHeight: 120)
.frame(minHeight: 120, alignment: .topLeading)
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))

Text(RecordPresentation.text("development_goal_create_markdown_hint"))
.font(.caption)
.foregroundStyle(Color.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.background(Color.surface, in: .rect(cornerRadius: 24))
Expand All @@ -184,7 +201,7 @@ private struct GoalCreateDescriptionEditor: View {

private struct GoalCreateModePicker: View {
@Bindable var store: StoreOf<GoalCreateFeature>
@FocusState var focusedField: GoalCreateField?
let focusedField: FocusState<GoalCreateField?>.Binding

var body: some View {
HStack(spacing: 0) {
Expand All @@ -193,13 +210,13 @@ private struct GoalCreateModePicker: View {
isSelected: store.selectedTab == .write
) {
store.send(.binding(.set(\.selectedTab, .write)))
focusedField = .content
focusedField.wrappedValue = .content
}
GoalCreateModeButton(
title: RecordPresentation.text("development_record_preview"),
isSelected: store.selectedTab == .preview
) {
focusedField = nil
focusedField.wrappedValue = nil
store.send(.binding(.set(\.selectedTab, .preview)))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,35 @@ public struct RecordEditorView: View {

switch store.selectedTab {
case .write:
TextEditor(text: $store.markdownContent)
.focused($focusedField, equals: .content)
.font(.body)
.scrollContentBackground(.hidden)
.padding(12)
.frame(minHeight: 340, alignment: .topLeading)
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))
TextEditorContentLayout(minimumHeight: 340 - 24) {
Text(RecordPresentation.text("development_record_markdown_hint"))
.font(.caption)
.foregroundStyle(Color.textTertiary)
UIKitTextEditor()
.composable(
update: { textEditor in
textEditor.updateInput(
text: $store.markdownContent,
isFocused: focusedField == .content,
onFocusChange: { isFocused in
if isFocused {
focusedField = .content
} else if focusedField == .content {
focusedField = nil
}
},
isEnabled: !store.isLoading
)
},
sizeThatFits: { UIKitTextEditor.fittingSize(proposal: $0, textEditor: $1) }
)
.focused($focusedField, equals: .content)
}
.padding(12)
.frame(minHeight: 340, alignment: .topLeading)
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))
.contentShape(.rect)
.onTapGesture { focusedField = .content }
case .preview:
Group {
if store.markdownContent.isEmpty {
Expand All @@ -164,10 +186,6 @@ public struct RecordEditorView: View {
.background(Color.surfaceSecondary, in: .rect(cornerRadius: 16))
}

Text(RecordPresentation.text("development_record_markdown_hint"))
.font(.caption)
.foregroundStyle(Color.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(20)
.background {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,55 +8,60 @@
import SwiftUI
import UIComposable

final class UIKitTextEditor: UITextView, UICoordinatedComposable {
var textBinding: Binding<String>?
var isEditorFocused = false
var onFocusChange: ((Bool) -> Void)?
var placeholder = ""
public final class UIKitTextEditor: UITextView, UICoordinatedComposable {
private var textBinding: Binding<String>?
private var isEditorFocused = false
private var onFocusChange: ((Bool) -> Void)?
private var placeholder: String?

func makeCoordinator() -> Coordinator {
public func makeCoordinator() -> Coordinator {
Coordinator()
}

func connect(coordinator: Coordinator) {
public func connect(coordinator: Coordinator) {
delegate = coordinator
coordinator.textView = self
configureAppearance()
}

func update(coordinator: Coordinator) {
public func update(coordinator: Coordinator) {
coordinator.update()
}

func disconnect(coordinator: Coordinator) {
public func disconnect(coordinator: Coordinator) {
if delegate === coordinator {
delegate = nil
}
coordinator.disconnect()
}

func updateInput(
public func updateInput(
text: Binding<String>,
isFocused: Bool,
onFocusChange: @escaping (Bool) -> Void,
placeholder: String
placeholder: String? = nil,
isEnabled: Bool = true
) {
textBinding = text
isEditorFocused = isFocused
self.onFocusChange = onFocusChange
self.placeholder = placeholder
isEditable = isEnabled
isSelectable = isEnabled
}

static func fittingSize(
public static func fittingSize(
proposal: ProposedViewSize,
textEditor: UIKitTextEditor
) -> CGSize? {
guard let width = proposal.width else { return nil }
guard let width = proposal.width, width.isFinite, 0 < width else { return nil }

let size = textEditor.sizeThatFits(
CGSize(width: width, height: .greatestFiniteMagnitude)
)
return CGSize(width: width, height: size.height)
let proposedHeight = proposal.height ?? 0
let height = proposedHeight.isFinite ? max(size.height, proposedHeight) : size.height
return CGSize(width: width, height: height)
}

private func configureAppearance() {
Expand All @@ -74,7 +79,7 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable {
setContentHuggingPriority(.defaultLow, for: .horizontal)
}

final class Coordinator: NSObject, UITextViewDelegate {
public final class Coordinator: NSObject, UITextViewDelegate {
weak var textView: UIKitTextEditor?
private weak var scrollView: UIScrollView?
private var offsetObservation: NSKeyValueObservation?
Expand Down Expand Up @@ -109,12 +114,12 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable {
textView = nil
}

func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
startTrackingOffset(for: textView)
return true
}

func textViewDidBeginEditing(_ textView: UITextView) {
public func textViewDidBeginEditing(_ textView: UITextView) {
guard let textView = textView as? UIKitTextEditor else { return }

if isShowingPlaceholder(in: textView) {
Expand All @@ -134,14 +139,14 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable {
}
}

func textViewDidChange(_ textView: UITextView) {
public func textViewDidChange(_ textView: UITextView) {
guard let textView = textView as? UIKitTextEditor else { return }

stopTrackingOffset()
textView.textBinding?.wrappedValue = textView.text
}

func textViewDidEndEditing(_ textView: UITextView) {
public func textViewDidEndEditing(_ textView: UITextView) {
guard let textView = textView as? UIKitTextEditor else { return }

if textView.isEditorFocused {
Expand All @@ -154,10 +159,11 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable {
}

func applyPlaceholderIfNeeded(to textView: UIKitTextEditor) {
guard let textBinding = textView.textBinding else { return }
guard let textBinding = textView.textBinding,
let placeholder = textView.placeholder else { return }

if textBinding.wrappedValue.isEmpty && !textView.isFirstResponder {
textView.text = textView.placeholder
textView.text = placeholder
textView.textColor = .placeholderText
} else if isShowingPlaceholder(in: textView) {
textView.text = textBinding.wrappedValue
Expand Down Expand Up @@ -231,6 +237,50 @@ final class UIKitTextEditor: UITextView, UICoordinatedComposable {
}
}

/// 안내 문구와 편집기를 순서대로 배치하고 남은 최소 높이를 편집기에 제안합니다.
public struct TextEditorContentLayout: Layout {
private let minimumHeight: CGFloat
private let spacing = CGFloat(8)

public init(minimumHeight: CGFloat) {
self.minimumHeight = max(0, minimumHeight)
}

public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard subviews.count == 2 else { return .zero }

let width = proposal.width.flatMap { $0.isFinite ? $0 : nil }
?? subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
let hintSize = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil))
let editorSize = subviews[1].sizeThatFits(ProposedViewSize(
width: width,
height: max(0, minimumHeight - hintSize.height - spacing)
))

return CGSize(width: width, height: max(minimumHeight, hintSize.height + spacing + editorSize.height))
}

public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count == 2 else { return }

let hintProposal = ProposedViewSize(width: bounds.width, height: nil)
let hintSize = subviews[0].sizeThatFits(hintProposal)
subviews[0].place(
at: CGPoint(x: bounds.minX, y: bounds.minY),
anchor: .topLeading,
proposal: hintProposal
)
subviews[1].place(
at: CGPoint(x: bounds.minX, y: bounds.minY + hintSize.height + spacing),
anchor: .topLeading,
proposal: ProposedViewSize(
width: bounds.width,
height: max(0, bounds.height - hintSize.height - spacing)
)
)
}
}

private extension UIView {
var enclosingScrollView: UIScrollView? {
var currentSuperview = superview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ private struct ContentView: View {
let isContentFocused = field == .content
Group {
if store.tabViewTag == .editor {
VStack(alignment: .leading, spacing: 8) {
TextEditorContentLayout(minimumHeight: max(0, minimumHeight - 20)) {
markdownHint
UIKitTextEditor()
.composable(
Expand Down
Loading