Skip to content

Commit 8759395

Browse files
authored
[#506] 토스트를 현재 씬에 떠 있는 최상위 뷰 기반으로 뜨도록 수정한다 (#533)
* chore: APPSTORE_URL -> TESTFLIGHT_URL * refactor: 앱 xcconfig 위치 정리 * refactor: ToastPresenter host 추가 * refactor: Home Todo toast 표시 이전 * refactor: PushNotification Account toast 표시 이전 * refactor: legacy toast modifier 제거 * refactor: MainActor 추가
1 parent 1ca458a commit 8759395

17 files changed

Lines changed: 218 additions & 159 deletions

File tree

Application/DevLogApp/Project.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ let project = Project(
3636
DevLogPackages.swiftLintPlugin,
3737
],
3838
settings: .devlog(
39-
versionXcconfigPath: "Sources/App.xcconfig",
39+
versionXcconfigPath: "Sources/Resource/App.xcconfig",
4040
base: [
4141
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
4242
"CODE_SIGN_STYLE": "Automatic",

Application/DevLogApp/Sources/App.xcconfig

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#include "../../../Shared/Version.xcconfig"
2+
#include? "Config.xcconfig"

Application/DevLogApp/Sources/Resource/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5-
<key>APPSTORE_URL</key>
6-
<string>$(APPSTORE_URL)</string>
5+
<key>TESTFLIGHT_URL</key>
6+
<string>$(TESTFLIGHT_URL)</string>
77
<key>APP_REDIRECT_URL</key>
88
<string>$(APP_REDIRECT_URL)</string>
99
<key>CFBundleDevelopmentRegion</key>

Application/DevLogPresentation/Sources/Common/Component/Toast.swift

Lines changed: 128 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,131 @@
66
//
77

88
import SwiftUI
9-
import DevLogDomain
109

11-
extension View {
12-
func toast<Label: View>(
13-
isPresented: Binding<Bool>,
10+
@MainActor
11+
@Observable
12+
final class ToastPresenter {
13+
fileprivate static let presenter = ToastPresenter()
14+
15+
private(set) var item: ToastItem?
16+
17+
private init() { }
18+
19+
static var item: ToastItem? {
20+
presenter.item
21+
}
22+
23+
static func present(
24+
message: String,
25+
systemImage: String? = nil,
1426
duration: TimeInterval = 2,
27+
font: Font? = nil,
28+
multilineTextAlignment: TextAlignment = .leading,
29+
lineLimit: Int? = nil,
1530
action: (() -> Void)? = nil,
16-
onDismiss: (() -> Void)? = nil,
17-
@ViewBuilder label: @escaping () -> Label
18-
) -> some View {
19-
self
31+
onDismiss: (() -> Void)? = nil
32+
) {
33+
presenter.present(
34+
ToastItem(
35+
message: message,
36+
systemImage: systemImage,
37+
duration: duration,
38+
font: font,
39+
multilineTextAlignment: multilineTextAlignment,
40+
lineLimit: lineLimit,
41+
action: action,
42+
onDismiss: onDismiss
43+
)
44+
)
45+
}
46+
47+
static func reset() {
48+
presenter.item = nil
49+
}
50+
51+
private func present(_ item: ToastItem) {
52+
dismissImmediately()
53+
self.item = item
54+
}
55+
56+
fileprivate func dismiss(itemId: UUID) {
57+
guard let item,
58+
item.id == itemId else { return }
59+
self.item = nil
60+
}
61+
62+
private func dismissImmediately() {
63+
guard let item else { return }
64+
self.item = nil
65+
item.onDismiss?()
66+
}
67+
}
68+
69+
struct ToastItem: Identifiable {
70+
let id = UUID()
71+
let message: String
72+
let systemImage: String?
73+
let duration: TimeInterval
74+
let font: Font?
75+
let multilineTextAlignment: TextAlignment
76+
let lineLimit: Int?
77+
let action: (() -> Void)?
78+
let onDismiss: (() -> Void)?
79+
}
80+
81+
extension View {
82+
func toastHost() -> some View {
83+
modifier(ToastHostModifier())
84+
}
85+
}
86+
87+
private struct ToastHostModifier: ViewModifier {
88+
private let toastPresenter = ToastPresenter.presenter
89+
90+
func body(content: Content) -> some View {
91+
content
2092
.frame(maxWidth: .infinity, maxHeight: .infinity)
2193
.overlay(alignment: .bottom) {
22-
ToastOverlayView(
23-
isPresented: isPresented,
24-
duration: duration,
25-
action: action,
26-
onDismiss: onDismiss,
27-
label: label
28-
)
29-
.padding(.horizontal, 12)
94+
if let item = toastPresenter.item {
95+
ToastOverlayView(
96+
isPresented: Binding(
97+
get: { toastPresenter.item?.id == item.id },
98+
set: { isPresented in
99+
if !isPresented {
100+
toastPresenter.dismiss(itemId: item.id)
101+
}
102+
}
103+
),
104+
duration: item.duration,
105+
action: item.action,
106+
onDismiss: item.onDismiss
107+
) {
108+
ToastItemLabel(item: item)
109+
}
110+
.id(item.id)
111+
.padding(.horizontal, 12)
112+
}
30113
}
31114
}
32115
}
33116

117+
private struct ToastItemLabel: View {
118+
let item: ToastItem
119+
120+
var body: some View {
121+
Group {
122+
if let systemImage = item.systemImage {
123+
Label(item.message, systemImage: systemImage)
124+
} else {
125+
Text(item.message)
126+
}
127+
}
128+
.font(item.font)
129+
.multilineTextAlignment(item.multilineTextAlignment)
130+
.lineLimit(item.lineLimit)
131+
}
132+
}
133+
34134
private struct ToastOverlayView<Label: View>: View {
35135
@Binding var isPresented: Bool
36136
let duration: TimeInterval
@@ -41,6 +141,7 @@ private struct ToastOverlayView<Label: View>: View {
41141
@State private var yOffset: CGFloat = 0
42142
@State private var opacityValue: Double = 0
43143
@State private var dismissWorkItem: DispatchWorkItem?
144+
@State private var dismissCompletionWorkItem: DispatchWorkItem?
44145
@State private var isTapped: Bool = false
45146
@State private var isScheduled: Bool = false
46147

@@ -65,6 +166,9 @@ private struct ToastOverlayView<Label: View>: View {
65166
presentAnimated()
66167
scheduleDismissIfNeeded()
67168
}
169+
.onDisappear {
170+
cleanupPresentation()
171+
}
68172
.onTapGesture {
69173
isTapped = true
70174
dismissAnimated()
@@ -86,6 +190,8 @@ private struct ToastOverlayView<Label: View>: View {
86190
private func resetForNewPresentation() {
87191
dismissWorkItem?.cancel()
88192
dismissWorkItem = nil
193+
dismissCompletionWorkItem?.cancel()
194+
dismissCompletionWorkItem = nil
89195
isScheduled = false
90196
isTapped = false
91197
yOffset = 0
@@ -95,6 +201,8 @@ private struct ToastOverlayView<Label: View>: View {
95201
private func cleanupPresentation() {
96202
dismissWorkItem?.cancel()
97203
dismissWorkItem = nil
204+
dismissCompletionWorkItem?.cancel()
205+
dismissCompletionWorkItem = nil
98206
isScheduled = false
99207
isTapped = false
100208
yOffset = 0
@@ -115,13 +223,14 @@ private struct ToastOverlayView<Label: View>: View {
115223
private func dismissAnimated() {
116224
dismissWorkItem?.cancel()
117225
dismissWorkItem = nil
226+
dismissCompletionWorkItem?.cancel()
118227

119228
withAnimation(.easeInOut(duration: 0.2)) {
120229
yOffset = 0
121230
opacityValue = 0
122231
}
123232

124-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
233+
let workItem = DispatchWorkItem {
125234
isPresented = false
126235
isScheduled = false
127236

@@ -130,6 +239,8 @@ private struct ToastOverlayView<Label: View>: View {
130239
}
131240
isTapped = false
132241
}
242+
dismissCompletionWorkItem = workItem
243+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem)
133244
}
134245
}
135246

Application/DevLogPresentation/Sources/Home/Home/HomeView.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,6 @@ struct HomeView: View {
7171
} message: {
7272
Text(coordinator.viewModel.state.alertMessage)
7373
}
74-
.toast(
75-
isPresented: Binding(
76-
get: { coordinator.viewModel.state.showToast },
77-
set: { coordinator.viewModel.send(.setToast(isPresented: $0)) }
78-
),
79-
duration: 5,
80-
action: { coordinator.viewModel.send(.undoDeleteWebPage) }
81-
) {
82-
Label(coordinator.viewModel.state.toastMessage, systemImage: "arrow.uturn.left")
83-
.font(.caption)
84-
.multilineTextAlignment(.center)
85-
}
8674
.overlay {
8775
if coordinator.viewModel.state.isAppending {
8876
LoadingView()

Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,18 @@ final class HomeViewModel: Store {
3232
var alertTitle: String = ""
3333
var alertType: AlertType?
3434
var alertMessage: String = ""
35-
var showToast: Bool = false
36-
var toastType: ToastType?
37-
var toastMessage: String = ""
3835
}
3936

4037
enum Action {
4138
case fetchData
4239
case networkStatusChanged(Bool)
4340
case setPresentation(Presentation, Bool)
4441
case setAlert(isPresented: Bool, type: AlertType? = nil)
45-
case setToast(isPresented: Bool, type: ToastType? = nil)
4642
case refreshWebPages
4743
case setLoading(LoadingTarget, Bool)
4844
case setWebPageHidden(URL, Bool)
4945
case handleWebPageDeleteFailure(URL)
46+
case finishDeleteWebPageToast(String)
5047
case tapTodoCategory(TodoCategory)
5148
case orderTodoCategory([TodoCategoryItem])
5249
case setTodoCategory([TodoCategoryItem])
@@ -75,10 +72,6 @@ final class HomeViewModel: Store {
7572
case error
7673
}
7774

78-
enum ToastType {
79-
case deleteWebPage
80-
}
81-
8275
enum ModalType {
8376
case todoEditor
8477
case urlInputAlert
@@ -143,9 +136,9 @@ final class HomeViewModel: Store {
143136
switch action {
144137
case .networkStatusChanged(let isConnected):
145138
state.isNetworkConnected = isConnected
146-
case .fetchData, .setPresentation, .setAlert, .setToast, .refreshWebPages,
139+
case .fetchData, .setPresentation, .setAlert, .refreshWebPages,
147140
.tapTodoCategory, .orderTodoCategory, .updateWebPageURLInput,
148-
.addWebPage, .deleteWebPage, .undoDeleteWebPage:
141+
.addWebPage, .deleteWebPage, .undoDeleteWebPage, .finishDeleteWebPageToast:
149142
effects = reduceByView(action, state: &state)
150143

151144
case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory,
@@ -269,12 +262,6 @@ private extension HomeViewModel {
269262
return [.showModalAfterDelay(.urlInputAlert)]
270263
}
271264
setAlert(&state, isPresented: presented, type: type)
272-
case .setToast(let isPresented, let type):
273-
setToast(&state, isPresented: isPresented, for: type)
274-
if !isPresented {
275-
state.webPages.removeAll { $0.isHidden }
276-
deletedWebPageURLString = nil
277-
}
278265
case .tapTodoCategory(let category):
279266
state.selectedTodoCategory = category
280267
state.showContentPicker = false
@@ -294,9 +281,10 @@ private extension HomeViewModel {
294281
return [.addWebPage(normalizedURL)]
295282
case .deleteWebPage(let page):
296283
if let index = state.webPages.firstIndex(where: { $0.id == page.id }) {
297-
deletedWebPageURLString = page.url.absoluteString
284+
let urlString = page.url.absoluteString
285+
deletedWebPageURLString = urlString
298286
state.webPages[index].isHidden = true
299-
setToast(&state, isPresented: true, for: .deleteWebPage)
287+
presentDeleteWebPageToast(urlString)
300288
return [.deleteWebPage(page)]
301289
}
302290
case .undoDeleteWebPage:
@@ -308,6 +296,11 @@ private extension HomeViewModel {
308296
}
309297
self.deletedWebPageURLString = nil
310298
return [.undoDeleteWebPage(deletedWebPageURLString)]
299+
case .finishDeleteWebPageToast(let urlString):
300+
state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden }
301+
if deletedWebPageURLString == urlString {
302+
deletedWebPageURLString = nil
303+
}
311304
default:
312305
break
313306
}
@@ -388,19 +381,20 @@ private extension HomeViewModel {
388381
state.alertType = type
389382
}
390383

391-
func setToast(
392-
_ state: inout State,
393-
isPresented: Bool,
394-
for type: ToastType?
395-
) {
396-
switch type {
397-
case .deleteWebPage:
398-
state.toastMessage = String(localized: "common_undo")
399-
case .none:
400-
state.toastMessage = ""
401-
}
402-
state.showToast = isPresented
403-
state.toastType = type
384+
func presentDeleteWebPageToast(_ urlString: String) {
385+
ToastPresenter.present(
386+
message: String(localized: "common_undo"),
387+
systemImage: "arrow.uturn.left",
388+
duration: 5,
389+
font: .caption,
390+
multilineTextAlignment: .center,
391+
action: { [weak self] in
392+
self?.send(.undoDeleteWebPage)
393+
},
394+
onDismiss: { [weak self] in
395+
self?.send(.finishDeleteWebPageToast(urlString))
396+
}
397+
)
404398
}
405399

406400
func setLoading(

Application/DevLogPresentation/Sources/Home/TodoListView.swift

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,6 @@ struct TodoListView: View {
6565
} message: {
6666
Text(viewModel.state.alertMessage)
6767
}
68-
.toast(
69-
isPresented: Binding(
70-
get: { viewModel.state.showToast },
71-
set: { viewModel.send(.setToast(isPresented: $0)) }
72-
),
73-
duration: 5,
74-
action: { viewModel.send(.undoDelete) }
75-
) {
76-
Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left")
77-
}
7868
.navigationTitle(TodoCategoryItem(from: viewModel.category).localizedName)
7969
.fullScreenCover(isPresented: Binding(
8070
get: { viewModel.state.showEditor },

0 commit comments

Comments
 (0)