Skip to content

Commit c3a8954

Browse files
graycreateclaude
andauthored
feat: add feed filter menu with Reddit-style dropdown (#45)
* 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> * refactor: redesign filter menu with Reddit-style dropdown Changes: - Change title display: Show "V2EX" for default "all" tab, show tab name for other selections - Redesign menu as left-aligned dropdown (like Reddit) instead of centered modal - Add icons for each tab category (house, flame, briefcase, etc.) - Replace grid layout with vertical list layout - Add checkmark indicator for selected item - Improve visual hierarchy with better spacing and typography - Add subtle shadow and spring animation for smoother transitions UI improvements: - Menu now anchors to top-left below title - Width: 200pt, max height: 450pt - Each item shows icon + label + checkmark (when selected) - Selected items have blue accent color with light background - Better touch targets with full-width clickable areas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused columns property in FilterMenuView The columns property was leftover from the initial grid layout design and is no longer needed after switching to vertical list layout. Addresses Copilot review comment. * fix: filter menu now properly overlays content instead of pushing it down - Moved filter menu rendering from TopBar to MainPage as an overlay - FilterMenuView now renders above content with proper z-index stacking - Removed duplicate overlay logic from FeedPage - Adjusted FilterMenuView positioning to work as a true overlay - Menu now appears on top of feed content without affecting layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove padding between topbar and filter dropdown menu - Removed .padding(.top, 8) from FilterMenuView to eliminate gap - Dropdown menu now appears directly connected to the topbar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add soft haptic feedback when toggling filter dropdown menu - Added light haptic feedback when tapping title to show/hide menu - Added haptic feedback when dismissing menu by tapping background - Added haptic feedback when selecting a menu item - Provides better tactile response for user interactions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add visual tap feedback to filter menu items - Added pressed state tracking with @State variable - Implemented scale effect (0.97x) when pressed for visual feedback - Added opacity change (0.7) when pressed for better visibility - Added pressed state background color for clear tap indication - Used onLongPressGesture with minimumDuration: 0 for immediate feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: extend tap area to full width of filter menu items - Removed alignment constraint from VStack to allow full width - Added frame(maxWidth: .infinity) to expand items to container width - Added contentShape(Rectangle()) to make entire area tappable - Now clicking anywhere on the menu item row triggers the action 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: critical navigation blocking issue caused by FilterMenuView overlay The FilterMenuView's ZStack was always present in the view hierarchy and blocking all touch events even when not visible, preventing users from: - Opening any feed list items - Accessing pages from Me tab (Posts, Topics, Favorites, Following, etc.) Added .allowsHitTesting modifier to only intercept touches when menu is shown. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: navigation completely broken on real device due to FilterMenuView The FilterMenuView component was always present in view hierarchy even when hidden, causing NavigationLink gesture conflicts on real devices. Changes: - Only render FilterMenuView when actually needed (conditional rendering) - Removed isShowing check inside FilterMenuView since it's now controlled externally - Eliminated view hierarchy pollution when menu is not shown - Fixed navigation for all affected areas: * Feed list items now open properly * Me page navigation items work again (Posts, Topics, Favorites, etc.) This issue only affected real devices, not simulators, due to different gesture recognition behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: increase tap area for filter menu trigger in navbar - Added horizontal padding (20pt) to expand clickable area - Added vertical padding (8pt) for easier tapping - Added contentShape(Rectangle()) to ensure entire padded area is tappable - Makes filter menu much easier to trigger, especially on real devices 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address Copilot PR review comments - Fixed incorrect copyright year (2025 -> 2024) in FilterMenuView.swift - Removed unused hidden grid icon from TopBar.swift to reduce code complexity - Cleaned up dead code that was always hidden These changes address the automated review feedback while maintaining all functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address additional Copilot PR review comments - Fixed rotation angle logic in TopBar to use conditional assignment instead of continuous increment - Extracted duplicate login check logic in FilterMenuView into local variable - Reduced code duplication in FeedReducer by extracting supportsLoadMore() calls into variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: revert rotation animation and add progress indicator - Reverted rotation angle logic to continuous increment for smoother animation - Added progress view display when switching tabs to improve UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: auto-scroll to top when switching filter menu items - Added scrollToTop state property to FeedState - Modified FeedReducer to trigger scroll after filter change data loads - Updated FeedPage to use both state and global scroll triggers - Ensures users see new content from the beginning after filter change This improves UX by automatically scrolling the feed list to the top when users select a different filter category, preventing confusion from seeing mid-scroll content from the previous filter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c207b1 commit c3a8954

File tree

8 files changed

+399
-98
lines changed

8 files changed

+399
-98
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: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,23 @@ 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+
let supportsLoadMore = state.selectedTab.supportsLoadMore()
27+
state.hasMoreData = supportsLoadMore
28+
// Trigger scroll to top after successfully loading new filter data
29+
if action.isFromFilterChange {
30+
state.scrollToTop = Int.random(in: 1...Int.max)
31+
}
2632
} else { }
2733
case let action as FeedActions.LoadMore.Start:
2834
guard !state.refreshing else { break }
2935
guard !state.loadingMore else { break }
36+
guard state.selectedTab.supportsLoadMore() else { break }
3037
state.loadingMore = true
3138
break
3239
case let action as FeedActions.LoadMore.Done:
3340
state.loadingMore = false
34-
state.hasMoreData = true // todo check vary tabs
41+
let supportsLoadMore = state.selectedTab.supportsLoadMore()
42+
state.hasMoreData = supportsLoadMore
3543
if case let .success(newsInfo) = action.result {
3644
state.willLoadPage += 1
3745
state.feedInfo.append(feedInfo: newsInfo!)
@@ -40,6 +48,16 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
4048
}
4149
case let action as FeedActions.ClearMsgBadge:
4250
state.feedInfo.unReadNums = 0
51+
case let action as FeedActions.SelectTab:
52+
state.selectedTab = action.tab
53+
Tab.saveSelectedTab(action.tab)
54+
state.showFilterMenu = false
55+
state.showProgressView = true
56+
let supportsLoadMore = action.tab.supportsLoadMore()
57+
state.hasMoreData = supportsLoadMore
58+
followingAction = FeedActions.FetchData.Start(isFromFilterChange: true)
59+
case let action as FeedActions.ToggleFilterMenu:
60+
state.showFilterMenu.toggle()
4361
default:
4462
break
4563
}
@@ -53,21 +71,34 @@ struct FeedActions {
5371
struct FetchData {
5472
struct Start: AwaitAction {
5573
var target: Reducer = reducer
56-
let tab: Tab = .all
5774
var page: Int = 0
5875
var autoLoad: Bool = false
76+
var isFromFilterChange: Bool = false
77+
78+
init(page: Int = 0, autoLoad: Bool = false, isFromFilterChange: Bool = false) {
79+
self.page = page
80+
self.autoLoad = autoLoad
81+
self.isFromFilterChange = isFromFilterChange
82+
}
5983

6084
func execute(in store: Store) async {
85+
let tab = store.appState.feedState.selectedTab
6186
let result: APIResult<FeedInfo> = await APIService.shared
6287
.htmlGet(endpoint: .tab, ["tab": tab.rawValue])
63-
dispatch(FetchData.Done(result: result))
88+
dispatch(FetchData.Done(result: result, isFromFilterChange: isFromFilterChange))
6489
}
6590
}
6691

6792
struct Done: Action {
6893
var target: Reducer = reducer
6994

7095
let result: APIResult<FeedInfo>
96+
let isFromFilterChange: Bool
97+
98+
init(result: APIResult<FeedInfo>, isFromFilterChange: Bool = false) {
99+
self.result = result
100+
self.isFromFilterChange = isFromFilterChange
101+
}
71102
}
72103
}
73104

@@ -98,4 +129,13 @@ struct FeedActions {
98129
var target: Reducer = reducer
99130
}
100131

132+
struct SelectTab: Action {
133+
var target: Reducer = reducer
134+
let tab: Tab
135+
}
136+
137+
struct ToggleFilterMenu: Action {
138+
var target: Reducer = reducer
139+
}
140+
101141
}

V2er/State/DataFlow/State/FeedState.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,7 @@ 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
21+
var scrollToTop: Int = 0 // Trigger scroll to top when changed
1922
}

V2er/View/Feed/FeedPage.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ struct FeedPage: BaseHomePageView {
4242
}
4343
}
4444
}
45-
.updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, scrollTop(tab: .feed)) {
45+
.updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed))) {
4646
if AccountState.hasSignIn() {
4747
await run(action: FeedActions.FetchData.Start())
4848
}
4949
} loadMore: {
50-
if AccountState.hasSignIn() {
50+
if AccountState.hasSignIn() && state.selectedTab.supportsLoadMore() {
5151
await run(action: FeedActions.LoadMore.Start(state.willLoadPage))
5252
}
5353
}

0 commit comments

Comments
 (0)