Skip to content

Commit 80aefac

Browse files
graycreateclaude
andcommitted
feat: add filter menu support to feed page
Implement tab filtering functionality similar to Android version, allowing users to filter feed content by different categories (技术, 创意, 好玩, Apple, etc.) Changes: - Add FilterMenuView: 3-column grid menu displaying all 13 V2EX tabs - Update Tab enum: Add displayName() fix, needsLogin(), supportsLoadMore(), tab persistence - Update FeedState: Add selectedTab and showFilterMenu properties - Update FeedReducer: Add SelectTab and ToggleFilterMenu actions - Update TopBar: Display selected tab name with chevron indicator - Update FeedPage: Integrate FilterMenuView with ZStack overlay - Restrict load more to "all" tab only (matching V2EX API behavior) Fixes: - Tab.displayName() now returns correct values instead of empty string 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5c207b1 commit 80aefac

File tree

7 files changed

+245
-44
lines changed

7 files changed

+245
-44
lines changed

V2er.xcodeproj/project.pbxproj

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
archiveVersion = 1;
44
classes = {
55
};
6-
objectVersion = 52;
6+
objectVersion = 54;
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */; };
1011
4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; };
1112
4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; };
1213
4EC32AF229D818FC003A3BD4 /* WebBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */; };
@@ -168,6 +169,7 @@
168169
/* End PBXContainerItemProxy section */
169170

170171
/* Begin PBXFileReference section */
172+
A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMenuView.swift; sourceTree = "<group>"; };
171173
4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = "<group>"; };
172174
5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = "<group>"; };
173175
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = "<group>"; };
@@ -203,7 +205,6 @@
203205
5D2B2B3926FF5DF800446F93 /* AccountInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfo.swift; sourceTree = "<group>"; };
204206
5D2B2B3B26FF754F00446F93 /* Persist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persist.swift; sourceTree = "<group>"; };
205207
5D2B2B3D26FF797600446F93 /* AccountState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountState.swift; sourceTree = "<group>"; };
206-
5DA0000000000000000001 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
207208
5D2DD00726FB353C0001C85A /* DefaultReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultReducer.swift; sourceTree = "<group>"; };
208209
5D2DD00926FB443D0001C85A /* GlobalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalActions.swift; sourceTree = "<group>"; };
209210
5D368C8726C419D000794B8E /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
@@ -263,6 +264,7 @@
263264
5D91F8D426F22A6F0089D72E /* TagDetailState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailState.swift; sourceTree = "<group>"; };
264265
5D91F8D826F22CEC0089D72E /* TagDetailReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailReducer.swift; sourceTree = "<group>"; };
265266
5D9D5222269543DA00D80D6B /* TagDetailPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailPage.swift; sourceTree = "<group>"; };
267+
5DA0000000000000000001 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
266268
5DA2AD3426C17EA5007FB1EF /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
267269
5DA2AD3626C17EB9007FB1EF /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
268270
5DA2AD3826C17ECC007FB1EF /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = "<group>"; };
@@ -343,14 +345,6 @@
343345
/* End PBXFrameworksBuildPhase section */
344346

345347
/* Begin PBXGroup section */
346-
5DA0000000000000000002 /* Config */ = {
347-
isa = PBXGroup;
348-
children = (
349-
5DA0000000000000000001 /* Version.xcconfig */,
350-
);
351-
path = Config;
352-
sourceTree = "<group>";
353-
};
354348
5D179BFD2496F6EC00E40E90 /* Widget */ = {
355349
isa = PBXGroup;
356350
children = (
@@ -513,6 +507,7 @@
513507
children = (
514508
5D71DF54247C0FFE00B53ED4 /* FeedPage.swift */,
515509
5D6AAAAE2692036100F42A13 /* FeedItemView.swift */,
510+
A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */,
516511
);
517512
path = Feed;
518513
sourceTree = "<group>";
@@ -566,6 +561,14 @@
566561
path = Reducers;
567562
sourceTree = "<group>";
568563
};
564+
5DA0000000000000000002 /* Config */ = {
565+
isa = PBXGroup;
566+
children = (
567+
5DA0000000000000000001 /* Version.xcconfig */,
568+
);
569+
path = Config;
570+
sourceTree = "<group>";
571+
};
569572
5DA2AD3326C17E7F007FB1EF /* State */ = {
570573
isa = PBXGroup;
571574
children = (
@@ -930,6 +933,7 @@
930933
5D74653D2705B97F0020F1F8 /* UpdatableState.swift in Sources */,
931934
5D368C8826C419D000794B8E /* APIService.swift in Sources */,
932935
5D88D5E026C2017E00302265 /* MeReducer.swift in Sources */,
936+
28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */,
933937
5DD4639E26F70CE800A1FBA1 /* LoginPage.swift in Sources */,
934938
5D1D7B8B26FD7FCE008E0C08 /* DailyInfo.swift in Sources */,
935939
5DA2AD4426C18121007FB1EF /* FeedState.swift in Sources */,

V2er/State/DataFlow/Model/TabInfo.swift

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,36 +24,59 @@ enum Tab: String {
2424
case members
2525

2626
func displayName() -> String {
27-
var name: String? = nil
28-
switch(self) {
27+
switch self {
2928
case .all:
30-
name = "全部"
29+
return "全部"
3130
case .tech:
32-
name = "技术"
31+
return "技术"
3332
case .creative:
34-
name = "创意"
33+
return "创意"
3534
case .play:
36-
name = "好玩"
35+
return "好玩"
3736
case .apple:
38-
name = "Apple"
37+
return "Apple"
3938
case .jobs:
40-
name = "酷工作"
39+
return "酷工作"
4140
case .deals:
42-
name = "交易"
41+
return "交易"
4342
case .city:
44-
name = "城市"
43+
return "城市"
4544
case .qna:
46-
name = "问与答"
45+
return "问与答"
4746
case .hot:
48-
name = "最热"
47+
return "最热"
4948
case .r2:
50-
name = "r2"
49+
return "R2"
5150
case .nodes:
52-
name = "节点"
51+
return "节点"
5352
case .members:
54-
name = "关注"
53+
return "关注"
5554
}
56-
assert(name != nil , "Tab display name shouldn't be null")
57-
return ""
55+
}
56+
57+
func needsLogin() -> Bool {
58+
return self == .nodes || self == .members
59+
}
60+
61+
func supportsLoadMore() -> Bool {
62+
return self == .all
63+
}
64+
65+
static var allTabs: [Tab] {
66+
return [.all, .tech, .creative, .play, .apple, .jobs, .deals, .city, .qna, .hot, .r2, .nodes, .members]
67+
}
68+
69+
private static let selectedTabKey = "selected_feed_tab"
70+
71+
static func saveSelectedTab(_ tab: Tab) {
72+
UserDefaults.standard.set(tab.rawValue, forKey: selectedTabKey)
73+
}
74+
75+
static func getSelectedTab() -> Tab {
76+
if let value = UserDefaults.standard.string(forKey: selectedTabKey),
77+
let tab = Tab(rawValue: value) {
78+
return tab
79+
}
80+
return .all
5881
}
5982
}

V2er/State/DataFlow/Reducers/FeedReducer.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
2323
if case let .success(newsInfo) = action.result {
2424
state.feedInfo = newsInfo ?? FeedInfo()
2525
state.willLoadPage = 1
26+
state.hasMoreData = state.selectedTab.supportsLoadMore()
2627
} else { }
2728
case let action as FeedActions.LoadMore.Start:
2829
guard !state.refreshing else { break }
2930
guard !state.loadingMore else { break }
31+
guard state.selectedTab.supportsLoadMore() else { break }
3032
state.loadingMore = true
3133
break
3234
case let action as FeedActions.LoadMore.Done:
3335
state.loadingMore = false
34-
state.hasMoreData = true // todo check vary tabs
36+
state.hasMoreData = state.selectedTab.supportsLoadMore()
3537
if case let .success(newsInfo) = action.result {
3638
state.willLoadPage += 1
3739
state.feedInfo.append(feedInfo: newsInfo!)
@@ -40,6 +42,14 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
4042
}
4143
case let action as FeedActions.ClearMsgBadge:
4244
state.feedInfo.unReadNums = 0
45+
case let action as FeedActions.SelectTab:
46+
state.selectedTab = action.tab
47+
Tab.saveSelectedTab(action.tab)
48+
state.showFilterMenu = false
49+
state.hasMoreData = action.tab.supportsLoadMore()
50+
followingAction = FeedActions.FetchData.Start()
51+
case let action as FeedActions.ToggleFilterMenu:
52+
state.showFilterMenu.toggle()
4353
default:
4454
break
4555
}
@@ -53,11 +63,11 @@ struct FeedActions {
5363
struct FetchData {
5464
struct Start: AwaitAction {
5565
var target: Reducer = reducer
56-
let tab: Tab = .all
5766
var page: Int = 0
5867
var autoLoad: Bool = false
5968

6069
func execute(in store: Store) async {
70+
let tab = store.appState.feedState.selectedTab
6171
let result: APIResult<FeedInfo> = await APIService.shared
6272
.htmlGet(endpoint: .tab, ["tab": tab.rawValue])
6373
dispatch(FetchData.Done(result: result))
@@ -98,4 +108,13 @@ struct FeedActions {
98108
var target: Reducer = reducer
99109
}
100110

111+
struct SelectTab: Action {
112+
var target: Reducer = reducer
113+
let tab: Tab
114+
}
115+
116+
struct ToggleFilterMenu: Action {
117+
var target: Reducer = reducer
118+
}
119+
101120
}

V2er/State/DataFlow/State/FeedState.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ struct FeedState: FluxState {
1616
var willLoadPage: Int = 0
1717
var hasMoreData: Bool = true
1818
var feedInfo: FeedInfo = FeedInfo()
19+
var selectedTab: Tab = Tab.getSelectedTab()
20+
var showFilterMenu: Bool = false
1921
}

V2er/View/Feed/FeedPage.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,24 @@ struct FeedPage: BaseHomePageView {
2424
}
2525

2626
var body: some View {
27-
contentView
28-
.hide(!isSelected)
29-
.onAppear {
30-
log("FeedPage.onAppear")
31-
}
27+
ZStack {
28+
contentView
29+
.hide(!isSelected)
30+
.onAppear {
31+
log("FeedPage.onAppear")
32+
}
33+
34+
FilterMenuView(
35+
selectedTab: state.selectedTab,
36+
isShowing: state.showFilterMenu,
37+
onTabSelected: { tab in
38+
dispatch(FeedActions.SelectTab(tab: tab))
39+
},
40+
onDismiss: {
41+
dispatch(FeedActions.ToggleFilterMenu())
42+
}
43+
)
44+
}
3245
}
3346

3447
@ViewBuilder
@@ -47,7 +60,7 @@ struct FeedPage: BaseHomePageView {
4760
await run(action: FeedActions.FetchData.Start())
4861
}
4962
} loadMore: {
50-
if AccountState.hasSignIn() {
63+
if AccountState.hasSignIn() && state.selectedTab.supportsLoadMore() {
5164
await run(action: FeedActions.LoadMore.Start(state.willLoadPage))
5265
}
5366
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//
2+
// FilterMenuView.swift
3+
// V2er
4+
//
5+
// Created by Claude on 2025/10/08.
6+
// Copyright © 2025 lessmore.io. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
struct FilterMenuView: View {
12+
@EnvironmentObject private var store: Store
13+
let selectedTab: Tab
14+
let isShowing: Bool
15+
let onTabSelected: (Tab) -> Void
16+
let onDismiss: () -> Void
17+
18+
private let columns = [
19+
GridItem(.flexible()),
20+
GridItem(.flexible()),
21+
GridItem(.flexible())
22+
]
23+
24+
var body: some View {
25+
ZStack {
26+
if isShowing {
27+
// Background overlay
28+
Color.black.opacity(0.3)
29+
.ignoresSafeArea()
30+
.onTapGesture {
31+
onDismiss()
32+
}
33+
.transition(.opacity)
34+
35+
// Menu content
36+
VStack(spacing: 0) {
37+
ScrollView {
38+
LazyVGrid(columns: columns, spacing: 12) {
39+
ForEach(Tab.allTabs, id: \.self) { tab in
40+
TabFilterButton(
41+
tab: tab,
42+
isSelected: tab == selectedTab,
43+
needsLogin: tab.needsLogin() && !AccountState.hasSignIn()
44+
) {
45+
if tab.needsLogin() && !AccountState.hasSignIn() {
46+
Toast.show("登录后才能查看「\(tab.displayName())」下的内容")
47+
} else {
48+
onTabSelected(tab)
49+
}
50+
}
51+
}
52+
}
53+
.padding()
54+
}
55+
.background(Color.itemBg)
56+
.cornerRadius(12)
57+
.padding()
58+
.frame(maxHeight: 400)
59+
}
60+
.transition(.move(edge: .top).combined(with: .opacity))
61+
}
62+
}
63+
.animation(.easeInOut(duration: 0.25), value: isShowing)
64+
}
65+
}
66+
67+
struct TabFilterButton: View {
68+
let tab: Tab
69+
let isSelected: Bool
70+
let needsLogin: Bool
71+
let action: () -> Void
72+
73+
var body: some View {
74+
Button(action: action) {
75+
Text(tab.displayName())
76+
.font(.system(size: 14))
77+
.foregroundColor(textColor)
78+
.frame(maxWidth: .infinity)
79+
.padding(.vertical, 12)
80+
.background(backgroundColor)
81+
.cornerRadius(8)
82+
.overlay(
83+
RoundedRectangle(cornerRadius: 8)
84+
.stroke(borderColor, lineWidth: isSelected ? 1.5 : 0)
85+
)
86+
}
87+
.opacity(needsLogin ? 0.5 : 1.0)
88+
}
89+
90+
private var textColor: Color {
91+
if isSelected {
92+
return Color.dynamic(light: .hex(0x2E7EF3), dark: .hex(0x5E9EFF))
93+
} else {
94+
return Color.primaryText
95+
}
96+
}
97+
98+
private var backgroundColor: Color {
99+
if isSelected {
100+
return Color.dynamic(light: .hex(0xE8F2FF), dark: .hex(0x1A3A52))
101+
} else {
102+
return Color.dynamic(light: .hex(0xF5F5F5), dark: .hex(0x2C2C2E))
103+
}
104+
}
105+
106+
private var borderColor: Color {
107+
return Color.dynamic(light: .hex(0x2E7EF3), dark: .hex(0x5E9EFF))
108+
}
109+
}
110+
111+
#if DEBUG
112+
struct FilterMenuView_Previews: PreviewProvider {
113+
static var previews: some View {
114+
FilterMenuView(
115+
selectedTab: .all,
116+
isShowing: true,
117+
onTabSelected: { _ in },
118+
onDismiss: {}
119+
)
120+
.environmentObject(Store.shared)
121+
}
122+
}
123+
#endif

0 commit comments

Comments
 (0)