Skip to content

Commit

Permalink
Merge branch 'feature/NavigationStack' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
Starkrimson committed Nov 1, 2022
2 parents 988300d + 6d6224c commit 5c42047
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 52 deletions.
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}
```

### 网络请求
Expand Down
6 changes: 5 additions & 1 deletion Shared/Model/ViewModel/CityViewModel.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Shared/State/DataFlow/SearchState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ struct SearchState: Equatable {
var list: [Find.City] = []
var status: Status = .normal

@BindableState var selectedCity: CityViewModel?

enum Status: Equatable {
case normal, loading, noResult, failed(String)
}
Expand Down
18 changes: 17 additions & 1 deletion Shared/State/DataFlow/WeatherState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct WeatherEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
var weatherClient: WeatherClient
var followingClient: FollowingClient
var date: () -> Date
}

let weatherReducer = Reducer<WeatherState, WeatherAction, WeatherEnvironment>.combine(
Expand All @@ -35,6 +36,21 @@ let weatherReducer = Reducer<WeatherState, WeatherAction, WeatherEnvironment>.co
followingClient: $0.followingClient
)
}
)
),
Reducer<WeatherState, WeatherAction, WeatherEnvironment> { 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 || (environment.date().timeIntervalSince1970 - Double(forecast!.current.dt)) > 600 {
return .init(value: .forecast(.loadCityForecast(city: city)))
}
}
return .none

default: return .none
}
}
)

34 changes: 14 additions & 20 deletions Shared/View/Forecast/CityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
})
}
}
}
Expand Down
53 changes: 30 additions & 23 deletions Shared/View/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,36 @@ struct SearchView: View {
let store: Store<WeatherState, WeatherAction>

var searchStore: Store<SearchState, SearchAction> {
store.scope(state: \.search,
action: WeatherAction.search)
store.scope(
state: \.search,
action: WeatherAction.search
)
}

var forecastStore: Store<ForecastState, ForecastAction> {
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 {
Expand All @@ -41,8 +44,18 @@ struct SearchView: View {
}
}
#endif

Image(systemName: "cloud.sun").font(.largeTitle)
} detail: {
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)
}
}
}
}
}
Expand Down Expand Up @@ -73,7 +86,8 @@ struct SearchView_Previews: PreviewProvider {
},
oneCall: { _,_ in Effect(error: .badURL) }
),
followingClient: .live
followingClient: .live,
date: Date.init
)
)
return SearchView(store: store)
Expand All @@ -89,7 +103,6 @@ private extension Text {

struct SearchSection: View {
let viewStore: ViewStore<SearchState, SearchAction>
let forecastStore: Store<ForecastState, ForecastAction>

var body: some View {
switch (viewStore.status, viewStore.searchQuery.count) {
Expand All @@ -100,10 +113,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)
}
}
Expand All @@ -120,10 +130,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)
Expand Down
7 changes: 6 additions & 1 deletion Shared/WeatherApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions WeatherApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1330;
LastUpgradeCheck = 1330;
LastUpgradeCheck = 1400;
ORGANIZATIONNAME = "";
TargetAttributes = {
0979807327F6ECDD0034DE5E = {
Expand Down Expand Up @@ -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)";
Expand All @@ -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)";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -679,7 +681,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",
Expand Down Expand Up @@ -718,7 +720,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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions WeatherAppTests/WeatherAppTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,43 @@ class WeatherAppTests: XCTestCase {
$0.loadingCityIDSet = []
}
}

func testSelectCity() {
let store = TestStore(
initialState: .init(),
reducer: weatherReducer,
environment: .init(
mainQueue: .immediate,
weatherClient: .failing,
followingClient: .falling,
date: { Date(timeIntervalSince1970: 1648699300) }
)
)

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]
}

// 再次点击城市,两次间隔不超过 600 秒,不触发刷新
store.send(.search(.binding(.set(\.$selectedCity, city))))
}
}

private let mockCities: [Find.City] = {
Expand Down

0 comments on commit 5c42047

Please sign in to comment.