From 9f6cb90e8fc26c91433209f9b48f2a664ae3a43f Mon Sep 17 00:00:00 2001 From: Tsui Date: Fri, 8 Jul 2022 17:02:45 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BD=BF=E7=94=A8=20WWDC22=20=E6=96=B0=20a?= =?UTF-8?q?pi=20NavigationSplitView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shared/Model/ViewModel/CityViewModel.swift | 6 ++- Shared/State/DataFlow/SearchState.swift | 2 + Shared/State/DataFlow/WeatherState.swift | 17 +++++++- Shared/View/Forecast/CityView.swift | 34 +++++++-------- Shared/View/SearchView.swift | 48 ++++++++++++---------- WeatherApp.xcodeproj/project.pbxproj | 8 ++-- WeatherAppTests/WeatherAppTests.swift | 36 ++++++++++++++++ 7 files changed, 103 insertions(+), 48 deletions(-) diff --git a/Shared/Model/ViewModel/CityViewModel.swift b/Shared/Model/ViewModel/CityViewModel.swift index 0093e9c..ca39e92 100644 --- a/Shared/Model/ViewModel/CityViewModel.swift +++ b/Shared/Model/ViewModel/CityViewModel.swift @@ -1,7 +1,7 @@ import Foundation import CoreData -struct CityViewModel: Identifiable, CustomStringConvertible, Equatable { +struct CityViewModel: Identifiable, CustomStringConvertible, Equatable, Hashable { init(city: Find.City) { self.city = city @@ -26,6 +26,10 @@ struct CityViewModel: Identifiable, CustomStringConvertible, Equatable { var description: String { "\(name), \(country)" } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } extension CityViewModel: ManagedObject { diff --git a/Shared/State/DataFlow/SearchState.swift b/Shared/State/DataFlow/SearchState.swift index e8529d9..a261f59 100644 --- a/Shared/State/DataFlow/SearchState.swift +++ b/Shared/State/DataFlow/SearchState.swift @@ -7,6 +7,8 @@ struct SearchState: Equatable { var list: [Find.City] = [] var status: Status = .normal + @BindableState var selectedCity: CityViewModel? = nil + enum Status: Equatable { case normal, loading, noResult, failed(String) } diff --git a/Shared/State/DataFlow/WeatherState.swift b/Shared/State/DataFlow/WeatherState.swift index dd970be..cff2b31 100644 --- a/Shared/State/DataFlow/WeatherState.swift +++ b/Shared/State/DataFlow/WeatherState.swift @@ -35,6 +35,21 @@ let weatherReducer = Reducer.co followingClient: $0.followingClient ) } - ) + ), + Reducer { state, action, environment in + switch action { + // MARK: - binding. 选择了 city,触发刷新天气预报。 + case .search(.binding(let binding)): + if binding.keyPath == \.$selectedCity, let city = state.search.selectedCity { + let forecast = state.forecast.forecast?[city.id] + if forecast == nil || (Date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 { + return .init(value: .forecast(.loadCityForecast(city: city))) + } + } + return .none + + default: return .none + } + } ) diff --git a/Shared/View/Forecast/CityView.swift b/Shared/View/Forecast/CityView.swift index 49ed65b..dcceb06 100644 --- a/Shared/View/Forecast/CityView.swift +++ b/Shared/View/Forecast/CityView.swift @@ -10,35 +10,29 @@ struct CityView: View { var body: some View { WithViewStore(store) { viewStore in - let forecast = viewStore.forecast?[city.id] ScrollView(.vertical) { - if let forecast = forecast { + if let forecast = viewStore.forecast?[city.id] { VStack(alignment: .leading) { CurrentView(current: forecast.current) HourlyView(hourly: forecast.hourly) DailyView(daily: forecast.daily, selectedDailyIndex: $selectedDailyIndex) } - .padding(.leading, 21) - .padding(.bottom, 20) + .padding(.leading, 21) + .padding(.bottom, 20) } } - .ignoresSafeArea(edges: .bottom) - .onAppear { - if forecast == nil || (Date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 { - viewStore.send(.loadCityForecast(city: city)) - } + .ignoresSafeArea(edges: .bottom) + .navigationTitle(city.description) + .navigationBarTitleDisplayMode(.large) + .navigationBarItems(trailing: Button(action: { + viewStore.send(.follow(city: city)) + }) { + if viewStore.followingList.contains(where: { $0.id == city.id }) { + EmptyView() + } else { + Image(systemName: "star") } - .navigationTitle(city.description) - .navigationBarTitleDisplayMode(.large) - .navigationBarItems(trailing: Button(action: { - viewStore.send(.follow(city: city)) - }) { - if viewStore.followingList.contains(where: { $0.id == city.id }) { - EmptyView() - } else { - Image(systemName: "star") - } - }) + }) } } } diff --git a/Shared/View/SearchView.swift b/Shared/View/SearchView.swift index ed0b95f..69741b6 100644 --- a/Shared/View/SearchView.swift +++ b/Shared/View/SearchView.swift @@ -6,33 +6,36 @@ struct SearchView: View { let store: Store var searchStore: Store { - store.scope(state: \.search, - action: WeatherAction.search) + store.scope( + state: \.search, + action: WeatherAction.search + ) } var forecastStore: Store { - store.scope(state: \.forecast, - action: WeatherAction.forecast) + store.scope( + state: \.forecast, + action: WeatherAction.forecast + ) } var body: some View { - WithViewStore(searchStore) { viewStore in - NavigationView { - List { - SearchSection(viewStore: viewStore, - forecastStore: forecastStore) + WithViewStore(searchStore) { searchViewStore in + NavigationSplitView { + List(selection: searchViewStore.binding(\.$selectedCity)) { + SearchSection(viewStore: searchViewStore) FollowingSection(store: forecastStore) } .listStyle(.sidebar) .navigationTitle("天气") .navigationBarTitleDisplayMode(.large) .searchable( - text: viewStore.binding(\.$searchQuery), + text: searchViewStore.binding(\.$searchQuery), placement: .navigationBarDrawer(displayMode: .always), prompt: "搜索城市" ) .onSubmit(of: .search) { - viewStore.send(.search(query: viewStore.searchQuery)) + searchViewStore.send(.search(query: searchViewStore.searchQuery)) } #if os(iOS) && !targetEnvironment(macCatalyst) .toolbar { @@ -41,8 +44,16 @@ struct SearchView: View { } } #endif - - Image(systemName: "cloud.sun").font(.largeTitle) + } detail: { + IfLetStore( + store.scope(state: \.search.selectedCity) + ) { letStore in + WithViewStore(letStore) { letViewStore in + CityView(store: forecastStore, city: letViewStore.state) + } + } else: { + Image(systemName: "cloud.sun").font(.largeTitle) + } } } } @@ -89,7 +100,6 @@ private extension Text { struct SearchSection: View { let viewStore: ViewStore - let forecastStore: Store var body: some View { switch (viewStore.status, viewStore.searchQuery.count) { @@ -100,10 +110,7 @@ struct SearchSection: View { case (.normal, _) where viewStore.list.count > 0: Section(header: Text("搜索结果").headerText()) { ForEach(viewStore.list) { city in - NavigationLink(destination: CityView( - store: forecastStore, - city: CityViewModel(city: city)) - ) { + NavigationLink(value: CityViewModel(city: city)) { CityRow(city: city) } } @@ -120,10 +127,7 @@ struct FollowingSection: View { WithViewStore(store) { viewStore in Section(header: Text("关注").headerText()) { ForEach(viewStore.followingList) { city in - NavigationLink(destination: CityView( - store: store, - city: city) - ) { + NavigationLink(value: city) { HStack { Text(city.description) .font(.headline) diff --git a/WeatherApp.xcodeproj/project.pbxproj b/WeatherApp.xcodeproj/project.pbxproj index 44b6ebe..44ef976 100644 --- a/WeatherApp.xcodeproj/project.pbxproj +++ b/WeatherApp.xcodeproj/project.pbxproj @@ -518,7 +518,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 3S89AQP84V; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.4; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.starkrimson.WeatherAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -538,7 +538,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 3S89AQP84V; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.4; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.starkrimson.WeatherAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -679,7 +679,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -718,7 +718,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/WeatherAppTests/WeatherAppTests.swift b/WeatherAppTests/WeatherAppTests.swift index 9246dc2..bdf6703 100644 --- a/WeatherAppTests/WeatherAppTests.swift +++ b/WeatherAppTests/WeatherAppTests.swift @@ -143,6 +143,42 @@ class WeatherAppTests: XCTestCase { $0.loadingCityIDSet = [] } } + + func testSelectCity() { + let store = TestStore( + initialState: .init(), + reducer: weatherReducer, + environment: .init( + mainQueue: .immediate, + weatherClient: .failing, + followingClient: .falling + ) + ) + + let city = CityViewModel(city: SearchView_Previews.debugList()[0]) + store.environment.weatherClient.oneCall = { _, _ in + .init(value: mockOneCall) + } + + // 测试选中城市 + store.send(.search(.binding(.set(\.$selectedCity, city)))) { + $0.search.selectedCity = city + } + + // 触发加载城市天气预报 + store.receive(.forecast(.loadCityForecast(city: city))) { + $0.forecast.loadingCityIDSet = [city.id] + } + + // 成功加载 + store.receive(.forecast(.loadCityForecastDone(cityID: city.id, result: .success(mockOneCall)))) { + $0.forecast.loadingCityIDSet = [] + $0.forecast.forecast = [city.id: mockOneCall] + } + + //TODO: 再次点击城市,两次间隔不超过 600 秒,不触发刷新 +// store.send(.search(.binding(.set(\.$selectedCity, city)))) + } } private let mockCities: [Find.City] = { From 0f129a86312d612dfe4709bc048b95e560b65aa6 Mon Sep 17 00:00:00 2001 From: Tsui Date: Fri, 8 Jul 2022 17:22:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?Date=20=E6=94=BE=E5=88=B0=20environment=20?= =?UTF-8?q?=E9=87=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shared/State/DataFlow/WeatherState.swift | 3 ++- Shared/View/SearchView.swift | 3 ++- Shared/WeatherApp.swift | 7 ++++++- WeatherApp.xcodeproj/project.pbxproj | 6 +++++- WeatherAppTests/WeatherAppTests.swift | 7 ++++--- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Shared/State/DataFlow/WeatherState.swift b/Shared/State/DataFlow/WeatherState.swift index cff2b31..374cf5e 100644 --- a/Shared/State/DataFlow/WeatherState.swift +++ b/Shared/State/DataFlow/WeatherState.swift @@ -15,6 +15,7 @@ struct WeatherEnvironment { var mainQueue: AnySchedulerOf var weatherClient: WeatherClient var followingClient: FollowingClient + var date: () -> Date } let weatherReducer = Reducer.combine( @@ -42,7 +43,7 @@ let weatherReducer = Reducer.co case .search(.binding(let binding)): if binding.keyPath == \.$selectedCity, let city = state.search.selectedCity { let forecast = state.forecast.forecast?[city.id] - if forecast == nil || (Date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 { + if forecast == nil || (environment.date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 { return .init(value: .forecast(.loadCityForecast(city: city))) } } diff --git a/Shared/View/SearchView.swift b/Shared/View/SearchView.swift index 69741b6..8c65734 100644 --- a/Shared/View/SearchView.swift +++ b/Shared/View/SearchView.swift @@ -84,7 +84,8 @@ struct SearchView_Previews: PreviewProvider { }, oneCall: { _,_ in Effect(error: .badURL) } ), - followingClient: .live + followingClient: .live, + date: Date.init ) ) return SearchView(store: store) diff --git a/Shared/WeatherApp.swift b/Shared/WeatherApp.swift index b9e63bf..7b0421c 100644 --- a/Shared/WeatherApp.swift +++ b/Shared/WeatherApp.swift @@ -8,7 +8,12 @@ struct WeatherApp: App { store: .init( initialState: .init(), reducer: weatherReducer, - environment: WeatherEnvironment(mainQueue: .main, weatherClient: .live, followingClient: .live) + environment: WeatherEnvironment( + mainQueue: .main, + weatherClient: .live, + followingClient: .live, + date: Date.init + ) ) ) .withHostingWindow { window in diff --git a/WeatherApp.xcodeproj/project.pbxproj b/WeatherApp.xcodeproj/project.pbxproj index 44ef976..b45b920 100644 --- a/WeatherApp.xcodeproj/project.pbxproj +++ b/WeatherApp.xcodeproj/project.pbxproj @@ -369,7 +369,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1330; + LastUpgradeCheck = 1400; ORGANIZATIONNAME = ""; TargetAttributes = { 0979807327F6ECDD0034DE5E = { @@ -584,6 +584,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -642,6 +643,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -749,6 +751,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 3S89AQP84V; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -779,6 +782,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 3S89AQP84V; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/WeatherAppTests/WeatherAppTests.swift b/WeatherAppTests/WeatherAppTests.swift index bdf6703..ec2c8f0 100644 --- a/WeatherAppTests/WeatherAppTests.swift +++ b/WeatherAppTests/WeatherAppTests.swift @@ -151,7 +151,8 @@ class WeatherAppTests: XCTestCase { environment: .init( mainQueue: .immediate, weatherClient: .failing, - followingClient: .falling + followingClient: .falling, + date: { Date(timeIntervalSince1970: 1648699300) } ) ) @@ -176,8 +177,8 @@ class WeatherAppTests: XCTestCase { $0.forecast.forecast = [city.id: mockOneCall] } - //TODO: 再次点击城市,两次间隔不超过 600 秒,不触发刷新 -// store.send(.search(.binding(.set(\.$selectedCity, city)))) + // 再次点击城市,两次间隔不超过 600 秒,不触发刷新 + store.send(.search(.binding(.set(\.$selectedCity, city)))) } } From afa188110540f6ed6a2ed6227786af1cfd25ee33 Mon Sep 17 00:00:00 2001 From: Tsui Date: Fri, 8 Jul 2022 17:35:09 +0800 Subject: [PATCH 3/4] update readme --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b8b534..debcaa0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 由 SwiftUI 驱动的跨平台 app,包括 UI 布局、状态管理、网络数据获取和本地数据存储等等。 -编译环境:macOS 12.0.1, Xcode 13.3, iOS 15.4 +编译环境:macOS 12.4, Xcode 14.0 beta, iOS 16.0 beta https://user-images.githubusercontent.com/16103570/160243859-863413ce-c1ca-4775-8c56-3a322cef9f30.mp4 @@ -120,6 +120,24 @@ NavigationView { // 第二个 view 为右侧 detail view Image(systemName: "cloud.sun").font(.largeTitle) } + +// iOS16.0+ macOS13.0+ +NavigationSplitView { // sidebar + List(selection: searchViewStore.binding(\.$selectedCity)) { + SearchSection(viewStore: searchViewStore) + FollowingSection(store: forecastStore) + } +} detail: { // detail + IfLetStore( + store.scope(state: \.search.selectedCity) + ) { letStore in + WithViewStore(letStore) { letViewStore in + CityView(store: forecastStore, city: letViewStore.state) + } + } else: { + Image(systemName: "cloud.sun").font(.largeTitle) + } +} ``` ### 网络请求 From 6d6224c9dd917364d0406edc43aa3cd548809c01 Mon Sep 17 00:00:00 2001 From: Tsui Date: Mon, 11 Jul 2022 11:36:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?Navigation=20Detail=20=E5=A5=97=E4=B8=80?= =?UTF-8?q?=E5=B1=82=20view=EF=BC=8C=E4=B8=8D=E7=84=B6=20if=20let=20?= =?UTF-8?q?=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shared/State/DataFlow/SearchState.swift | 2 +- Shared/View/SearchView.swift | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Shared/State/DataFlow/SearchState.swift b/Shared/State/DataFlow/SearchState.swift index a261f59..19f14c0 100644 --- a/Shared/State/DataFlow/SearchState.swift +++ b/Shared/State/DataFlow/SearchState.swift @@ -7,7 +7,7 @@ struct SearchState: Equatable { var list: [Find.City] = [] var status: Status = .normal - @BindableState var selectedCity: CityViewModel? = nil + @BindableState var selectedCity: CityViewModel? enum Status: Equatable { case normal, loading, noResult, failed(String) diff --git a/Shared/View/SearchView.swift b/Shared/View/SearchView.swift index 8c65734..7e0ede8 100644 --- a/Shared/View/SearchView.swift +++ b/Shared/View/SearchView.swift @@ -45,14 +45,16 @@ struct SearchView: View { } #endif } detail: { - IfLetStore( - store.scope(state: \.search.selectedCity) - ) { letStore in - WithViewStore(letStore) { letViewStore in - CityView(store: forecastStore, city: letViewStore.state) + VStack { + IfLetStore( + store.scope(state: \.search.selectedCity) + ) { letStore in + WithViewStore(letStore) { letViewStore in + CityView(store: forecastStore, city: letViewStore.state) + } + } else: { + Image(systemName: "cloud.sun").font(.largeTitle) } - } else: { - Image(systemName: "cloud.sun").font(.largeTitle) } } }