diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift index 0d846249..a8c64c9c 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift @@ -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 diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift index db434e03..f51c790c 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift @@ -205,6 +205,7 @@ struct SearchView: View { Divider() } } + .todoDetailPreview(todoId: item.id) } private func webResultRow(_ item: WebPageItem) -> some View { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailPreviewModifier.swift b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailPreviewModifier.swift new file mode 100644 index 00000000..ce193ec1 --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailPreviewModifier.swift @@ -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 { + 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) + } +} diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailPreviewView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailPreviewView.swift new file mode 100644 index 00000000..c69db75c --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailPreviewView.swift @@ -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 + + 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) } + } +} diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index 150532a9..794aa2f5 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -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) { @@ -279,6 +280,7 @@ public struct TodoListView: View { Divider() } } + .todoDetailPreview(todoId: todo.id) } .padding(.horizontal, 16) diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift b/Application/Presentation/TodayTab/Sources/Today/TodayView.swift index 5efb898f..c5fe1126 100644 --- a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/Today/TodayView.swift @@ -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 {