Skip to content

Commit f9aa6ea

Browse files
authored
[#78] SearchView에서 최근 검색을 보여주는 섹션을 구현한다 (#95)
* feat: 최근 검색어 최대 20개 저장 및 뷰에 띄우기 * feat: 최근 검색어를 탭하면 해당 검색어에 대해 결과를 띄워주기
1 parent ca8088f commit f9aa6ea

3 files changed

Lines changed: 107 additions & 12 deletions

File tree

DevLog/Presentation/ViewModel/SearchViewModel.swift

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ final class SearchViewModel: Store {
1515
var searchQuery: String = ""
1616
var selectedWebPage: WebPageItem?
1717
var webPages: OrderedSet<WebPageItem> = []
18+
var recentQueries: OrderedSet<String> = []
1819
var filteredWebPages: [WebPageItem] {
1920
webPages.filter {
2021
$0.title.localizedCaseInsensitiveContains(searchQuery) ||
@@ -30,6 +31,9 @@ final class SearchViewModel: Store {
3031
case onAppear
3132
case fetchWebPage([WebPageItem]? = nil)
3233
case selectWebPage(WebPageItem)
34+
case addRecentQuery(String)
35+
case removeRecentQuery(String)
36+
case clearRecentQueries
3337
case setAlert(Bool)
3438
case setLoading(Bool)
3539
case setSearching(Bool)
@@ -42,9 +46,21 @@ final class SearchViewModel: Store {
4246

4347
@Published private(set) var state: State = .init()
4448
private let fetchWebPagesUseCase: FetchWebPagesUseCase
49+
private let userDefaults: UserDefaults
4550

46-
init(fetchWebPagesUseCase: FetchWebPagesUseCase) {
51+
private enum DefaultsKey {
52+
static let recentQueries = "Search.recentQueries"
53+
}
54+
55+
private let maxRecentQueries = 20
56+
57+
init(
58+
fetchWebPagesUseCase: FetchWebPagesUseCase,
59+
userDefaults: UserDefaults = .standard
60+
) {
4761
self.fetchWebPagesUseCase = fetchWebPagesUseCase
62+
self.userDefaults = userDefaults
63+
self.state.recentQueries = Self.loadRecentQueries(userDefaults: userDefaults)
4864
}
4965

5066
func reduce(with action: Action) -> [SideEffect] {
@@ -63,6 +79,21 @@ final class SearchViewModel: Store {
6379
state.webPages = OrderedSet(items)
6480
case .selectWebPage(let item):
6581
state.selectedWebPage = item
82+
case .addRecentQuery(let query):
83+
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
84+
guard !trimmed.isEmpty else { break }
85+
state.recentQueries.remove(trimmed)
86+
state.recentQueries.insert(trimmed, at: 0)
87+
if maxRecentQueries < state.recentQueries.count {
88+
state.recentQueries = OrderedSet(state.recentQueries.prefix(maxRecentQueries))
89+
}
90+
saveRecentQueries(Array(state.recentQueries))
91+
case .removeRecentQuery(let query):
92+
state.recentQueries.remove(query)
93+
saveRecentQueries(Array(state.recentQueries))
94+
case .clearRecentQueries:
95+
state.recentQueries = []
96+
saveRecentQueries([])
6697
case .setAlert(let isPresented):
6798
setAlert(&state, isPresented: isPresented)
6899
case .setLoading(let isLoading):
@@ -103,4 +134,12 @@ private extension SearchViewModel {
103134
state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요."
104135
state.showAlert = isPresented
105136
}
137+
138+
static func loadRecentQueries(userDefaults: UserDefaults) -> OrderedSet<String> {
139+
OrderedSet(userDefaults.stringArray(forKey: DefaultsKey.recentQueries) ?? [])
140+
}
141+
142+
func saveRecentQueries(_ queries: [String]) {
143+
userDefaults.set(queries, forKey: DefaultsKey.recentQueries)
144+
}
106145
}

DevLog/Resource/Localizable.xcstrings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,9 @@
352352
},
353353
"작성된 내용이 없습니다." : {
354354

355+
},
356+
"전체 삭제" : {
357+
355358
},
356359
"정렬 옵션" : {
357360

@@ -370,6 +373,9 @@
370373
},
371374
"지난주" : {
372375

376+
},
377+
"최근 검색" : {
378+
373379
},
374380
"최근에 중요 표시를 한 Todo가 여기 표시됩니다." : {
375381

DevLog/UI/Search/SearchView.swift

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ struct SearchView: View {
6666
if viewModel.state.isLoading {
6767
LoadingView()
6868
} else if viewModel.state.searchQuery.isEmpty {
69-
searchInstruction
69+
if viewModel.state.recentQueries.isEmpty {
70+
searchInstruction
71+
} else {
72+
recentQueries
73+
}
7074
} else if viewModel.state.filteredWebPages.isEmpty {
7175
emptySearchResult
7276
} else {
@@ -76,20 +80,25 @@ struct SearchView: View {
7680
.frame(maxWidth: .infinity, alignment: .leading)
7781
}
7882

79-
if #available(iOS 17.0, *) {
80-
scrollContent.searchable(
81-
text: searchQueryBinding,
82-
isPresented: searchingBinding,
83-
placement: .navigationBarDrawer(displayMode: .always),
84-
prompt: "검색"
85-
)
86-
} else {
87-
scrollContent
88-
.searchable(
83+
Group {
84+
if #available(iOS 17.0, *) {
85+
scrollContent.searchable(
8986
text: searchQueryBinding,
87+
isPresented: searchingBinding,
9088
placement: .navigationBarDrawer(displayMode: .always),
9189
prompt: "검색"
9290
)
91+
} else {
92+
scrollContent
93+
.searchable(
94+
text: searchQueryBinding,
95+
placement: .navigationBarDrawer(displayMode: .always),
96+
prompt: "검색"
97+
)
98+
}
99+
}
100+
.onSubmit(of: .search) {
101+
viewModel.send(.addRecentQuery(viewModel.state.searchQuery))
93102
}
94103
}
95104

@@ -153,6 +162,47 @@ struct SearchView: View {
153162
.padding(.vertical, 8)
154163
}
155164

165+
private var recentQueries: some View {
166+
VStack(alignment: .leading, spacing: 12) {
167+
HStack {
168+
Text("최근 검색")
169+
.font(.headline)
170+
.foregroundStyle(Color(.label))
171+
Spacer()
172+
Button("전체 삭제") {
173+
viewModel.send(.clearRecentQueries)
174+
}
175+
.font(.subheadline)
176+
.foregroundStyle(Color.gray)
177+
}
178+
179+
ForEach(viewModel.state.recentQueries, id: \.self) { query in
180+
HStack {
181+
Image(systemName: "clock.arrow.circlepath")
182+
.foregroundStyle(Color.gray)
183+
Text(query)
184+
.foregroundStyle(Color.primary)
185+
Spacer()
186+
Button {
187+
viewModel.send(.removeRecentQuery(query))
188+
} label: {
189+
Image(systemName: "xmark.circle.fill")
190+
.foregroundStyle(Color.gray)
191+
}
192+
.buttonStyle(.plain)
193+
}
194+
.padding(.vertical, 4)
195+
.contentShape(Rectangle())
196+
.onTapGesture {
197+
viewModel.send(.setSearchQuery(query))
198+
viewModel.send(.setSearching(true))
199+
}
200+
}
201+
}
202+
.padding(.horizontal, 16)
203+
.padding(.vertical, 8)
204+
}
205+
156206
private enum Path: Hashable {
157207
case webView(URL)
158208
}

0 commit comments

Comments
 (0)