diff --git a/V2er.xcodeproj/project.pbxproj b/V2er.xcodeproj/project.pbxproj index cb73cf9..07f9f2f 100644 --- a/V2er.xcodeproj/project.pbxproj +++ b/V2er.xcodeproj/project.pbxproj @@ -3,10 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */; }; 4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; }; 4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; }; 4EC32AF229D818FC003A3BD4 /* WebBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */; }; @@ -168,6 +169,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMenuView.swift; sourceTree = ""; }; 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = ""; }; 5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = ""; }; 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = ""; }; @@ -203,7 +205,6 @@ 5D2B2B3926FF5DF800446F93 /* AccountInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfo.swift; sourceTree = ""; }; 5D2B2B3B26FF754F00446F93 /* Persist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persist.swift; sourceTree = ""; }; 5D2B2B3D26FF797600446F93 /* AccountState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountState.swift; sourceTree = ""; }; - 5DA0000000000000000001 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; 5D2DD00726FB353C0001C85A /* DefaultReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultReducer.swift; sourceTree = ""; }; 5D2DD00926FB443D0001C85A /* GlobalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalActions.swift; sourceTree = ""; }; 5D368C8726C419D000794B8E /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; @@ -263,6 +264,7 @@ 5D91F8D426F22A6F0089D72E /* TagDetailState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailState.swift; sourceTree = ""; }; 5D91F8D826F22CEC0089D72E /* TagDetailReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailReducer.swift; sourceTree = ""; }; 5D9D5222269543DA00D80D6B /* TagDetailPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailPage.swift; sourceTree = ""; }; + 5DA0000000000000000001 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; 5DA2AD3426C17EA5007FB1EF /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 5DA2AD3626C17EB9007FB1EF /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 5DA2AD3826C17ECC007FB1EF /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; @@ -343,14 +345,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 5DA0000000000000000002 /* Config */ = { - isa = PBXGroup; - children = ( - 5DA0000000000000000001 /* Version.xcconfig */, - ); - path = Config; - sourceTree = ""; - }; 5D179BFD2496F6EC00E40E90 /* Widget */ = { isa = PBXGroup; children = ( @@ -513,6 +507,7 @@ children = ( 5D71DF54247C0FFE00B53ED4 /* FeedPage.swift */, 5D6AAAAE2692036100F42A13 /* FeedItemView.swift */, + A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */, ); path = Feed; sourceTree = ""; @@ -566,6 +561,14 @@ path = Reducers; sourceTree = ""; }; + 5DA0000000000000000002 /* Config */ = { + isa = PBXGroup; + children = ( + 5DA0000000000000000001 /* Version.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; 5DA2AD3326C17E7F007FB1EF /* State */ = { isa = PBXGroup; children = ( @@ -930,6 +933,7 @@ 5D74653D2705B97F0020F1F8 /* UpdatableState.swift in Sources */, 5D368C8826C419D000794B8E /* APIService.swift in Sources */, 5D88D5E026C2017E00302265 /* MeReducer.swift in Sources */, + 28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */, 5DD4639E26F70CE800A1FBA1 /* LoginPage.swift in Sources */, 5D1D7B8B26FD7FCE008E0C08 /* DailyInfo.swift in Sources */, 5DA2AD4426C18121007FB1EF /* FeedState.swift in Sources */, diff --git a/V2er/State/DataFlow/Model/TabInfo.swift b/V2er/State/DataFlow/Model/TabInfo.swift index bebb0f9..4da431f 100644 --- a/V2er/State/DataFlow/Model/TabInfo.swift +++ b/V2er/State/DataFlow/Model/TabInfo.swift @@ -24,36 +24,59 @@ enum Tab: String { case members func displayName() -> String { - var name: String? = nil - switch(self) { + switch self { case .all: - name = "全部" + return "全部" case .tech: - name = "技术" + return "技术" case .creative: - name = "创意" + return "创意" case .play: - name = "好玩" + return "好玩" case .apple: - name = "Apple" + return "Apple" case .jobs: - name = "酷工作" + return "酷工作" case .deals: - name = "交易" + return "交易" case .city: - name = "城市" + return "城市" case .qna: - name = "问与答" + return "问与答" case .hot: - name = "最热" + return "最热" case .r2: - name = "r2" + return "R2" case .nodes: - name = "节点" + return "节点" case .members: - name = "关注" + return "关注" } - assert(name != nil , "Tab display name shouldn't be null") - return "" + } + + func needsLogin() -> Bool { + return self == .nodes || self == .members + } + + func supportsLoadMore() -> Bool { + return self == .all + } + + static var allTabs: [Tab] { + return [.all, .tech, .creative, .play, .apple, .jobs, .deals, .city, .qna, .hot, .r2, .nodes, .members] + } + + private static let selectedTabKey = "selected_feed_tab" + + static func saveSelectedTab(_ tab: Tab) { + UserDefaults.standard.set(tab.rawValue, forKey: selectedTabKey) + } + + static func getSelectedTab() -> Tab { + if let value = UserDefaults.standard.string(forKey: selectedTabKey), + let tab = Tab(rawValue: value) { + return tab + } + return .all } } diff --git a/V2er/State/DataFlow/Reducers/FeedReducer.swift b/V2er/State/DataFlow/Reducers/FeedReducer.swift index 9f28289..dc66a01 100644 --- a/V2er/State/DataFlow/Reducers/FeedReducer.swift +++ b/V2er/State/DataFlow/Reducers/FeedReducer.swift @@ -23,15 +23,23 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio if case let .success(newsInfo) = action.result { state.feedInfo = newsInfo ?? FeedInfo() state.willLoadPage = 1 + let supportsLoadMore = state.selectedTab.supportsLoadMore() + state.hasMoreData = supportsLoadMore + // Trigger scroll to top after successfully loading new filter data + if action.isFromFilterChange { + state.scrollToTop = Int.random(in: 1...Int.max) + } } else { } case let action as FeedActions.LoadMore.Start: guard !state.refreshing else { break } guard !state.loadingMore else { break } + guard state.selectedTab.supportsLoadMore() else { break } state.loadingMore = true break case let action as FeedActions.LoadMore.Done: state.loadingMore = false - state.hasMoreData = true // todo check vary tabs + let supportsLoadMore = state.selectedTab.supportsLoadMore() + state.hasMoreData = supportsLoadMore if case let .success(newsInfo) = action.result { state.willLoadPage += 1 state.feedInfo.append(feedInfo: newsInfo!) @@ -40,6 +48,16 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio } case let action as FeedActions.ClearMsgBadge: state.feedInfo.unReadNums = 0 + case let action as FeedActions.SelectTab: + state.selectedTab = action.tab + Tab.saveSelectedTab(action.tab) + state.showFilterMenu = false + state.showProgressView = true + let supportsLoadMore = action.tab.supportsLoadMore() + state.hasMoreData = supportsLoadMore + followingAction = FeedActions.FetchData.Start(isFromFilterChange: true) + case let action as FeedActions.ToggleFilterMenu: + state.showFilterMenu.toggle() default: break } @@ -53,14 +71,21 @@ struct FeedActions { struct FetchData { struct Start: AwaitAction { var target: Reducer = reducer - let tab: Tab = .all var page: Int = 0 var autoLoad: Bool = false + var isFromFilterChange: Bool = false + + init(page: Int = 0, autoLoad: Bool = false, isFromFilterChange: Bool = false) { + self.page = page + self.autoLoad = autoLoad + self.isFromFilterChange = isFromFilterChange + } func execute(in store: Store) async { + let tab = store.appState.feedState.selectedTab let result: APIResult = await APIService.shared .htmlGet(endpoint: .tab, ["tab": tab.rawValue]) - dispatch(FetchData.Done(result: result)) + dispatch(FetchData.Done(result: result, isFromFilterChange: isFromFilterChange)) } } @@ -68,6 +93,12 @@ struct FeedActions { var target: Reducer = reducer let result: APIResult + let isFromFilterChange: Bool + + init(result: APIResult, isFromFilterChange: Bool = false) { + self.result = result + self.isFromFilterChange = isFromFilterChange + } } } @@ -98,4 +129,13 @@ struct FeedActions { var target: Reducer = reducer } + struct SelectTab: Action { + var target: Reducer = reducer + let tab: Tab + } + + struct ToggleFilterMenu: Action { + var target: Reducer = reducer + } + } diff --git a/V2er/State/DataFlow/State/FeedState.swift b/V2er/State/DataFlow/State/FeedState.swift index 8892b04..d572fd5 100644 --- a/V2er/State/DataFlow/State/FeedState.swift +++ b/V2er/State/DataFlow/State/FeedState.swift @@ -16,4 +16,7 @@ struct FeedState: FluxState { var willLoadPage: Int = 0 var hasMoreData: Bool = true var feedInfo: FeedInfo = FeedInfo() + var selectedTab: Tab = Tab.getSelectedTab() + var showFilterMenu: Bool = false + var scrollToTop: Int = 0 // Trigger scroll to top when changed } diff --git a/V2er/View/Feed/FeedPage.swift b/V2er/View/Feed/FeedPage.swift index bbc5ca0..58736ea 100644 --- a/V2er/View/Feed/FeedPage.swift +++ b/V2er/View/Feed/FeedPage.swift @@ -42,12 +42,12 @@ struct FeedPage: BaseHomePageView { } } } - .updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, scrollTop(tab: .feed)) { + .updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed))) { if AccountState.hasSignIn() { await run(action: FeedActions.FetchData.Start()) } } loadMore: { - if AccountState.hasSignIn() { + if AccountState.hasSignIn() && state.selectedTab.supportsLoadMore() { await run(action: FeedActions.LoadMore.Start(state.willLoadPage)) } } diff --git a/V2er/View/Feed/FilterMenuView.swift b/V2er/View/Feed/FilterMenuView.swift new file mode 100644 index 0000000..3b94415 --- /dev/null +++ b/V2er/View/Feed/FilterMenuView.swift @@ -0,0 +1,180 @@ +// +// FilterMenuView.swift +// V2er +// +// Created by Claude on 2024/10/08. +// Copyright © 2024 lessmore.io. All rights reserved. +// + +import SwiftUI + +struct FilterMenuView: View { + @EnvironmentObject private var store: Store + let selectedTab: Tab + let isShowing: Bool + let onTabSelected: (Tab) -> Void + let onDismiss: () -> Void + + var body: some View { + ZStack { + // Background overlay + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + // Soft haptic feedback + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + + onDismiss() + } + + // Menu content - positioned below navbar + VStack(spacing: 0) { + HStack { + Spacer() + ScrollView { + VStack(spacing: 4) { + ForEach(Tab.allTabs, id: \.self) { tab in + let tabNeedsLogin = tab.needsLogin() && !AccountState.hasSignIn() + TabFilterMenuItem( + tab: tab, + isSelected: tab == selectedTab, + needsLogin: tabNeedsLogin + ) { + // Soft haptic feedback + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + + if tabNeedsLogin { + Toast.show("登录后才能查看「\(tab.displayName())」下的内容") + } else { + onTabSelected(tab) + } + } + } + } + .padding(.vertical, 8) + } + .frame(width: 200) + .background(Color.itemBg) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 4) + .frame(maxHeight: 450) + Spacer() + } + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + + Spacer() + } + } + .transition(.opacity) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isShowing) + } +} + +struct TabFilterMenuItem: View { + let tab: Tab + let isSelected: Bool + let needsLogin: Bool + let action: () -> Void + + @State private var isPressed = false + + var body: some View { + HStack(spacing: 12) { + Image(systemName: iconName) + .font(.system(size: 16)) + .foregroundColor(iconColor) + .frame(width: 24) + + Text(tab.displayName()) + .font(.system(size: 15, weight: isSelected ? .semibold : .regular)) + .foregroundColor(textColor) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(iconColor) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(backgroundColor) + .contentShape(Rectangle()) + .scaleEffect(isPressed ? 0.97 : 1.0) + .opacity(needsLogin ? 0.5 : (isPressed ? 0.7 : 1.0)) + .onTapGesture { + action() + } + .onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity, pressing: { pressing in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = pressing + } + }, perform: {}) + } + + private var iconName: String { + switch tab { + case .all: return "house.fill" + case .tech: return "desktopcomputer" + case .creative: return "lightbulb.fill" + case .play: return "gamecontroller.fill" + case .apple: return "apple.logo" + case .jobs: return "briefcase.fill" + case .deals: return "cart.fill" + case .city: return "building.2.fill" + case .qna: return "questionmark.circle.fill" + case .hot: return "flame.fill" + case .r2: return "arrow.clockwise" + case .nodes: return "square.grid.3x3.fill" + case .members: return "person.2.fill" + } + } + + private var textColor: Color { + if isSelected { + return Color.dynamic(light: .hex(0x2E7EF3), dark: .hex(0x5E9EFF)) + } else { + return Color.primaryText + } + } + + private var iconColor: Color { + if isSelected { + return Color.dynamic(light: .hex(0x2E7EF3), dark: .hex(0x5E9EFF)) + } else { + return Color.dynamic(light: .hex(0x666666), dark: .hex(0x999999)) + } + } + + private var backgroundColor: Color { + if isPressed { + // Pressed state - slightly darker background + return Color.dynamic(light: .hex(0xE0E0E0).opacity(0.5), dark: .hex(0x2A2A2A).opacity(0.5)) + } else if isSelected { + return Color.dynamic(light: .hex(0xF0F7FF), dark: .hex(0x1A2533)) + } else { + return Color.clear + } + } +} + +#if DEBUG +struct FilterMenuView_Previews: PreviewProvider { + static var previews: some View { + FilterMenuView( + selectedTab: .all, + isShowing: true, + onTabSelected: { _ in }, + onDismiss: {} + ) + .environmentObject(Store.shared) + } +} +#endif diff --git a/V2er/View/MainPage.swift b/V2er/View/MainPage.swift index 5a2eab1..68082c9 100644 --- a/V2er/View/MainPage.swift +++ b/V2er/View/MainPage.swift @@ -25,10 +25,28 @@ struct MainPage: StateView { var body: some View { NavigationView { ZStack { - FeedPage(selecedTab: state.selectedTab) - ExplorePage(selecedTab: state.selectedTab) - MessagePage(selecedTab: state.selectedTab) - MePage(selecedTab: state.selectedTab) + // Main content pages + ZStack { + FeedPage(selecedTab: state.selectedTab) + ExplorePage(selecedTab: state.selectedTab) + MessagePage(selecedTab: state.selectedTab) + MePage(selecedTab: state.selectedTab) + } + + // Filter menu overlay - only render when needed + if state.selectedTab == .feed && store.appState.feedState.showFilterMenu { + FilterMenuView( + selectedTab: store.appState.feedState.selectedTab, + isShowing: true, + onTabSelected: { tab in + dispatch(FeedActions.SelectTab(tab: tab)) + }, + onDismiss: { + dispatch(FeedActions.ToggleFilterMenu()) + } + ) + .zIndex(1000) + } } .safeAreaInset(edge: .top, spacing: 0) { TopBar(selectedTab: state.selectedTab) diff --git a/V2er/View/Widget/TopBar.swift b/V2er/View/Widget/TopBar.swift index fe7e74f..3a37c1a 100644 --- a/V2er/View/Widget/TopBar.swift +++ b/V2er/View/Widget/TopBar.swift @@ -2,81 +2,114 @@ // TopBar.swift // V2er // -// Created by Seth on 2021/6/24. +// Created by Gray on 2021/6/24. // Copyright © 2021 lessmore.io. All rights reserved. // import SwiftUI struct TopBar: View { - var selectedTab : TabId - - private var isHomePage: Bool { - return selectedTab == .feed + @EnvironmentObject private var store: Store + var selectedTab : TabId + @State private var rotationAngle: Double = 0 + + private var isHomePage: Bool { + return selectedTab == .feed + } + + private var title: String { + switch selectedTab { + case .feed: + let selectedTab = store.appState.feedState.selectedTab + return selectedTab == .all ? "V2EX" : selectedTab.displayName() + case .explore: + return "发现" + case .message: + return "通知" + case .me: + return "我" + case .none: + return .empty } - - private var title: String { - switch selectedTab { - case .feed: - return "V2EX" - case .explore: - return "发现" - case .message: - return "通知" - case .me: - return "我" - case .none: - return .empty + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + HStack { + Spacer() + Image(systemName: "magnifyingglass") + .foregroundColor(.primary) + .font(.system(size: 22)) + .padding(6) + .forceClickable() + .to { SearchPage() } } - } - - var body: some View { - VStack(spacing: 0) { - ZStack { - HStack { - Image(systemName: "square.grid.2x2") - .foregroundColor(.primary) - .font(.system(size: 22)) - .padding(6) - .forceClickable() - .hide() -// .to { TestView() } - Spacer() - Image(systemName: "magnifyingglass") - .foregroundColor(.primary) - .font(.system(size: 22)) - .padding(6) - .forceClickable() - .to { SearchPage() } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - Text(title) - .font(isHomePage ? .title2 : .headline) - .foregroundColor(.primary) - .fontWeight(isHomePage ? .heavy : .bold) + .padding(.horizontal, 10) + .padding(.vertical, 8) + + // Centered title + HStack { + Spacer() + if isHomePage { + HStack(spacing: 4) { + Text(title) + .font(.title2) + .foregroundColor(.primary) + .fontWeight(.heavy) + // Rotate chevron when filter menu is open + Image(systemName: "chevron.down") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + .rotationEffect(.degrees(rotationAngle)) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.appState.feedState.showFilterMenu) + .accessibilityHidden(true) } - .padding(.top, topSafeAreaInset().top) - .background(VEBlur()) - - Divider() - .light() - } - .readSize { - print("size: \($0))") + .padding(.horizontal, 26) // Expand tap area horizontally + .padding(.vertical, 8) // Expand tap area vertically + .forceClickable() + .onChange(of: store.appState.feedState.showFilterMenu) { newValue in + withAnimation { + rotationAngle += 180 + } + } + .onTapGesture { + // Soft haptic feedback + let impactFeedback = UIImpactFeedbackGenerator(style: .soft) + impactFeedback.impactOccurred() + dispatch(FeedActions.ToggleFilterMenu(), .default) + } + } else { + Text(title) + .font(.headline) + .foregroundColor(.primary) + .fontWeight(.bold) + } + Spacer() } + .allowsHitTesting(isHomePage) + } + .padding(.top, topSafeAreaInset().top) + .background(VEBlur()) + + Divider() + .light() + } + .readSize { + print("size: \($0))") } + } } struct TopBar_Previews: PreviewProvider { -// @State static var selecedTab = TabId.feed - static var selecedTab = TabId.explore - - static var previews: some View { - VStack { - TopBar(selectedTab: selecedTab) - Spacer() - } - .ignoresSafeArea(.container) + // @State static var selecedTab = TabId.feed + static var selecedTab = TabId.explore + + static var previews: some View { + VStack { + TopBar(selectedTab: selecedTab) + Spacer() } + .ignoresSafeArea(.container) + } }