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
27 changes: 15 additions & 12 deletions Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -300,20 +300,23 @@ public struct HomeView: View {

@ViewBuilder
private func recentTodoRow(_ item: RecentTodoItem) -> some View {
if isCompactLayout {
NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) {
RecentTodoRow(todo: item)
}
} else {
Button {
coordinator.router.replace(with: .todo(TodoIdItem(id: item.id)))
} label: {
RecentTodoRow(todo: item)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
Group {
if isCompactLayout {
NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) {
RecentTodoRow(todo: item)
}
} else {
Button {
coordinator.router.replace(with: .todo(TodoIdItem(id: item.id)))
} label: {
RecentTodoRow(todo: item)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
.todoDetailPreview(todoId: item.id)
}

@ViewBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ struct SearchView: View {
Divider()
}
}
.todoDetailPreview(todoId: item.id)
}

private func webResultRow(_ item: WebPageItem) -> some View {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//
// TodoDetailPreviewModifier.swift
// PresentationShared
//
// Created by opfic on 8/11/26.
//

import SwiftUI
import ComposableArchitecture
import Core
import Domain

public extension View {
func todoDetailPreview(todoId: String) -> some View {
modifier(TodoDetailPreviewModifier(todoId: todoId))
}
}

private struct TodoDetailPreviewModifier: ViewModifier {
@Environment(\.diContainer) private var container
let todoId: String

func body(content: Content) -> some View {
content.overlay {
Comment thread
opficdev marked this conversation as resolved.
TodoDetailPreviewInteractionView(
todoId: todoId,
makePreview: makePreviewViewController
)
}
}

private func makePreviewViewController() -> UIViewController {
let store = Store(
initialState: TodoDetailFeature.State(
todoId: todoId,
showEditButton: false
)
) {
TodoDetailFeature()
} withDependencies: {
$0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self)
$0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self)
}
return UIHostingController(rootView: TodoDetailPreviewView(store: store))
}
}

private struct TodoDetailPreviewInteractionView: UIViewRepresentable {
let todoId: String
let makePreview: () -> UIViewController

func makeCoordinator() -> TodoDetailPreviewInteractionCoordinator {
TodoDetailPreviewInteractionCoordinator()
}

func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
view.isAccessibilityElement = false
context.coordinator.update(todoId: todoId, makePreview: makePreview)
context.coordinator.install(on: view)
return view
}

func updateUIView(_ view: UIView, context: Context) {
context.coordinator.update(todoId: todoId, makePreview: makePreview)
}
}

private final class TodoDetailPreviewInteractionCoordinator: NSObject {
private weak var view: UIView?
private var contextMenuInteraction: UIContextMenuInteraction?
private var todoId = ""
private var makePreview: (() -> UIViewController)?

func update(
todoId: String,
makePreview: @escaping () -> UIViewController
) {
self.todoId = todoId
self.makePreview = makePreview
}

func install(on view: UIView) {
guard self.view !== view else { return }

if let contextMenuInteraction {
self.view?.removeInteraction(contextMenuInteraction)
}

let contextMenuInteraction = UIContextMenuInteraction(delegate: self)
view.addInteraction(contextMenuInteraction)
self.view = view
self.contextMenuInteraction = contextMenuInteraction
}
}

extension TodoDetailPreviewInteractionCoordinator: UIContextMenuInteractionDelegate {
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
guard let makePreview else { return nil }

let preferredContentSize = preferredContentSize(for: interaction.view)
return UIContextMenuConfiguration(
identifier: todoId as NSString,
previewProvider: {
let viewController = makePreview()
viewController.preferredContentSize = preferredContentSize
return viewController
},
actionProvider: nil
)
}

// 미리보기가 표시될 때 원본 행에 기본 배경색이 적용되는 현상 방지
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configuration: UIContextMenuConfiguration,
highlightPreviewForItemWithIdentifier identifier: any NSCopying
) -> UITargetedPreview? {
targetedPreview()
}

// 미리보기가 닫힐 때 원본 행에 기본 배경색이 적용되는 현상 방지
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configuration: UIContextMenuConfiguration,
dismissalPreviewForItemWithIdentifier identifier: any NSCopying
) -> UITargetedPreview? {
targetedPreview()
}

// iOS 18 이하에서 투명한 원본 뷰의 기본 전환 배경이 흰색으로 표시되는 현상 방지
private func targetedPreview() -> UITargetedPreview? {
guard let view else { return nil }

let parameters = UIPreviewParameters()
parameters.backgroundColor = .clear
parameters.shadowPath = UIBezierPath()

return UITargetedPreview(
view: view,
parameters: parameters
)
}

private func preferredContentSize(for view: UIView?) -> CGSize {
let bounds = view?.window?.bounds ?? view?.bounds ?? .zero
let width = min(420, max(280, bounds.width - 32))
let height = min(640, max(320, bounds.height * 0.7))
return CGSize(width: width, height: height)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// TodoDetailPreviewView.swift
// PresentationShared
//
// Created by opfic on 8/11/26.
//

import SwiftUI
import ComposableArchitecture

struct TodoDetailPreviewView: View {
@State var store: StoreOf<TodoDetailFeature>

var body: some View {
ZStack {
Color(.systemGroupedBackground).ignoresSafeArea()
if let todo = store.todo {
TodoDetailContentView(
title: todo.title,
content: todo.content,
referenceItems: store.referenceItems,
number: todo.number,
onOpenTodoID: nil
)
} else if store.alert != nil {
ContentUnavailableView {
Label(
String(localized: "common_error_title"),
systemImage: "exclamationmark.triangle"
)
} description: {
Text(String(localized: "common_error_message"))
}
} else {
LoadingView()
}
}
.onAppear { store.send(.onAppear) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public struct TodoListView: View {
} label: {
TodoItemRow(todo)
}
.todoDetailPreview(todoId: todo.id)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.alignmentGuide(.listRowSeparatorLeading) { _ in return 0 }
.overlay(alignment: .top) {
Expand Down Expand Up @@ -279,6 +280,7 @@ public struct TodoListView: View {
Divider()
}
}
.todoDetailPreview(todoId: todo.id)
}
.padding(.horizontal, 16)

Expand Down
27 changes: 15 additions & 12 deletions Application/Presentation/TodayTab/Sources/Today/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,20 +155,23 @@ public struct TodayView: View {

@ViewBuilder
private func todoRow(_ item: TodayTodoItem) -> some View {
if isCompactLayout {
NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) {
TodayTodoRow(item: item)
}
} else {
Button {
coordinator.router.replace(with: .todo(TodoIdItem(id: item.id)))
} label: {
TodayTodoRow(item: item)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
Group {
if isCompactLayout {
NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) {
TodayTodoRow(item: item)
}
} else {
Button {
coordinator.router.replace(with: .todo(TodoIdItem(id: item.id)))
} label: {
TodayTodoRow(item: item)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
.todoDetailPreview(todoId: item.id)
}

private var emptyStateContent: EmptyStateContent {
Expand Down
Loading