diff --git a/Rewind.xcodeproj/project.pbxproj b/Rewind.xcodeproj/project.pbxproj index 7a106fb..fa0ecd2 100644 --- a/Rewind.xcodeproj/project.pbxproj +++ b/Rewind.xcodeproj/project.pbxproj @@ -457,7 +457,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_ASSET_PATHS = "\"Rewind/Preview Content\""; DEVELOPMENT_TEAM = AGFUBHFQD6; ENABLE_PREVIEWS = YES; @@ -475,7 +475,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.4; + MARKETING_VERSION = 1.4.5; PRODUCT_BUNDLE_IDENTIFIER = chizberg.Rewind; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -493,7 +493,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_ASSET_PATHS = "\"Rewind/Preview Content\""; DEVELOPMENT_TEAM = AGFUBHFQD6; ENABLE_PREVIEWS = YES; @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.4; + MARKETING_VERSION = 1.4.5; PRODUCT_BUNDLE_IDENTIFIER = chizberg.Rewind; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Rewind.xcodeproj/xcshareddata/xcschemes/RewindTests.xcscheme b/Rewind.xcodeproj/xcshareddata/xcschemes/RewindTests.xcscheme new file mode 100644 index 0000000..fd4a2f5 --- /dev/null +++ b/Rewind.xcodeproj/xcshareddata/xcschemes/RewindTests.xcscheme @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Rewind/App/AppGraph.swift b/Rewind/App/AppGraph.swift index a40e8c7..6a431a9 100644 --- a/Rewind/App/AppGraph.swift +++ b/Rewind/App/AppGraph.swift @@ -13,11 +13,9 @@ import VGSL final class AppGraph { let mapControlsStore: MapControlsStore let floatingMenuStore: FloatingMenu.Store + let rootViewMapStore: RootView.Map.Store let appStore: AppModel.Store - let onMapLoaded: () -> Void - let selectedImageKind: ObservedVariable - let map: Lazy let urlOpener: UrlOpener let imageLoader: ImageLoader @@ -83,8 +81,7 @@ final class AppGraph { action: { .external(.ui($0)) }, ) mapControlsStore = mapStore.makeControlsStore() - onMapLoaded = { mapModelRef?(.external(.ui(.mapViewLoaded))) } - selectedImageKind = mapModel.$state.filters.imageKind.skipRepeats().asObservedVariable() + rootViewMapStore = makeRootMapStore(mapStore: mapStore) let imageDetailsFactory = { image, source in makeImageDetailsModel( modelImage: image, @@ -158,6 +155,13 @@ final class AppGraph { settings.gradientScheme.asObservableVariable().onChange { appModelRef?(.setGradientScheme($0)) }.dispose(in: disposePool) + ObservableVariable.combineLatest( + mapModel.$state.controls.minimization.skipRepeats(), + mapModel.$state.controls.size.skipRepeats() + ).newValues.addObserver { minimization, size in + let hiddenPart = minimization.isMinimized ? mapControlsMinimizedOffset : 0 + map.value.updateBottomInset(size.height - hiddenPart) + }.dispose(in: disposePool) filters.current = mapModel.$state.filters.skipRepeats() // React to memory warnings by clearing image cache @@ -171,6 +175,11 @@ final class AppGraph { } } storeReview.appLaunched() + + assert( + map.currentValue == nil, + "the map is loaded too early, this can result in a runtime crash" + ) } deinit { diff --git a/Rewind/App/RewindApp.swift b/Rewind/App/RewindApp.swift index b6ab4bf..d071442 100644 --- a/Rewind/App/RewindApp.swift +++ b/Rewind/App/RewindApp.swift @@ -27,8 +27,7 @@ struct RewindApp: App { mapControlsStore: graph.mapControlsStore, floatingMenuStore: graph.floatingMenuStore, appStore: graph.appStore, - selectedImageKind: graph.selectedImageKind, - onMapLoaded: graph.onMapLoaded, + mapStore: graph.rootViewMapStore, ) .environment(\.openURL, OpenURLAction { graph.urlOpener($0) diff --git a/Rewind/App/RootView.swift b/Rewind/App/RootView.swift index a78463e..4163e91 100644 --- a/Rewind/App/RootView.swift +++ b/Rewind/App/RootView.swift @@ -9,15 +9,25 @@ import MapKit import SwiftUI struct RootView: View { - let rawMap: UIView + enum Map { + struct State { + var selectedImageKind: ImageRequestFilters.ImageKind + } + + enum Action { + case mapViewLoaded + case controlsSizeChanged(CGSize) + } + typealias Store = ViewStore + } + + let rawMap: UIView let mapControlsStore: MapControlsStore let floatingMenuStore: FloatingMenu.Store - let appStore: AppModel.Store - @ObservedVariable - var selectedImageKind: ImageRequestFilters.ImageKind - var onMapLoaded: () -> Void + let appStore: AppModel.Store + let mapStore: Map.Store enum TransitionSource { static let settings = "settings" @@ -34,7 +44,7 @@ struct RootView: View { var body: some View { content .environment(\.gradientScheme, appStore.gradientScheme) - .environment(\.maxRange, selectedImageKind.maxRange) + .environment(\.maxRange, mapStore.selectedImageKind.maxRange) } private var content: some View { @@ -45,7 +55,7 @@ struct RootView: View { .ignoresSafeArea() .task { if appStore.onboardingStore == nil { - onMapLoaded() + mapStore(.mapViewLoaded) } } @@ -58,7 +68,9 @@ struct RootView: View { namespace: rootView, hasBottomSafeAreaInset: geometry.safeAreaInsets.bottom > 0, floatingMenu: { floatingMenu } - ) + ).readSize { + mapStore(.controlsSizeChanged($0)) + } }.ignoresSafeArea(edges: .bottom) } } @@ -135,6 +147,26 @@ struct RootView: View { } } +func makeRootMapStore( + mapStore: MapViewModel.Store +) -> RootView.Map.Store { + mapStore.bimap( + state: { mapState in + RootView.Map.State( + selectedImageKind: mapState.filters.imageKind + ) + }, + action: { action in + switch action { + case .mapViewLoaded: + .mapViewLoaded + case let .controlsSizeChanged(size): + .controls(.sizeChanged(size)) + } + } + ) +} + private let screenRadius = DeviceModel.getCurrent().screenRadius() extension AppState { @@ -156,8 +188,7 @@ extension AppState { mapControlsStore: graph.mapControlsStore, floatingMenuStore: graph.floatingMenuStore, appStore: graph.appStore, - selectedImageKind: graph.selectedImageKind, - onMapLoaded: graph.onMapLoaded, + mapStore: graph.rootViewMapStore ) } #endif diff --git a/Rewind/Screens/Map/Controls/FloatingMenu.swift b/Rewind/Screens/Map/Controls/FloatingMenu.swift index c73000c..d7eba40 100644 --- a/Rewind/Screens/Map/Controls/FloatingMenu.swift +++ b/Rewind/Screens/Map/Controls/FloatingMenu.swift @@ -181,7 +181,7 @@ private struct FloatingMenuImpl: View { view } } - .animation(.smooth, value: expandedItems) + .animation(mapControlsAnimation, value: expandedItems) } } diff --git a/Rewind/Screens/Map/Controls/MapControls.swift b/Rewind/Screens/Map/Controls/MapControls.swift index 5414d2b..093f3df 100644 --- a/Rewind/Screens/Map/Controls/MapControls.swift +++ b/Rewind/Screens/Map/Controls/MapControls.swift @@ -63,7 +63,7 @@ struct MapControls: View { \.minimization, send: { .controls(.setMinimization($0)) }, ), offset: $offset, - glimpseHeight: 100, + glimpseHeight: glimpseHeight, pullingProgress: $pullingProgress, minPullLength: 300, onPull: { appAction(.imageList(.presentCurrentRegionImages( @@ -73,7 +73,7 @@ struct MapControls: View { ) } .animation( - .interactiveSpring(duration: 0.3, extraBounce: 0.1, blendDuration: 1), + mapControlsAnimation, value: offset, ) } @@ -206,6 +206,12 @@ struct MapControls: View { let mapControlsTouchBlockingHeight = glassCardHeight + makeBottomPadding(hasBottomSafeAreaInset: true) +let mapControlsMinimizedOffset = glassCardHeight - glimpseHeight +let mapControlsAnimation = Animation.interactiveSpring( + duration: 0.5, + extraBounce: 0.1, + blendDuration: 1 +) extension MapViewModel.Store { func makeControlsStore() -> MapControlsStore { @@ -342,6 +348,7 @@ private let containerPadding: CGFloat = 8 private let mapControlRadius: CGFloat = 25 private let glassCardPadding: CGFloat = 20 private let glassCardHeight = thumbnailSize.height + glassCardPadding * 2 +private let glimpseHeight: CGFloat = 100 private let glassCardRadius = max( DeviceModel.getCurrent().screenRadius() - containerPadding, 32, diff --git a/Rewind/Screens/Map/MapModel.swift b/Rewind/Screens/Map/MapModel.swift index b2263ae..4a8a2ef 100644 --- a/Rewind/Screens/Map/MapModel.swift +++ b/Rewind/Screens/Map/MapModel.swift @@ -17,6 +17,7 @@ struct MapState { struct ControlsState { var expandedItems: [FloatingMenu.Item] var minimization: MinimizationState + var size: CGSize } var mapType: MapType @@ -47,6 +48,7 @@ enum MapAction { enum Controls { case setMinimization(MinimizationState) case setExpandedItems([FloatingMenu.Item]) + case sizeChanged(CGSize) } case mapViewLoaded @@ -189,6 +191,8 @@ func makeMapModel( state.controls.minimization = minimization case let .setExpandedItems(items): state.controls.expandedItems = items + case let .sizeChanged(size): + state.controls.size = size } case .locationButtonTapped: let locationZoom = 17 @@ -372,6 +376,7 @@ extension MapState { controls: ControlsState( expandedItems: [], minimization: .normal, + size: .zero, ), ) } diff --git a/Rewind/Screens/Map/RewindMap.swift b/Rewind/Screens/Map/RewindMap.swift index 4d0c189..1c207c4 100644 --- a/Rewind/Screens/Map/RewindMap.swift +++ b/Rewind/Screens/Map/RewindMap.swift @@ -6,6 +6,7 @@ // import MapKit +import SwiftUI import VGSL @MainActor @@ -77,6 +78,13 @@ final class RewindMap { map.mapType = mapType.mkMapType } + func updateBottomInset(_ inset: CGFloat) { + UIView.animate(mapControlsAnimation) { + map.layoutMargins.bottom = inset + map.layoutIfNeeded() + } + } + private func remove(annotations: [MKAnnotation], completion: @escaping Action) { animateRemoval( annotations.compactMap { map.view(for: $0) }, @@ -103,6 +111,7 @@ private final class RewindMapView: MKMapView, UIGestureRecognizerDelegate { showsUserLocation = false isPitchEnabled = false isRotateEnabled = false + insetsLayoutMarginsFromSafeArea = false register( ImageAnnotationView.self, diff --git a/Rewind/Screens/Map/Zoom.swift b/Rewind/Screens/Map/Zoom.swift index 214fe36..f7b00b3 100644 --- a/Rewind/Screens/Map/Zoom.swift +++ b/Rewind/Screens/Map/Zoom.swift @@ -10,7 +10,7 @@ import VGSL // https://leafletjs.com/examples/zoom-levels/ func zoom(region: Region, mapSize: CGSize) -> Int { - let delta = min(region.span.latitudeDelta, region.span.longitudeDelta) + let delta = max(region.span.latitudeDelta, region.span.longitudeDelta) return Int( (log2(360 / delta) + adjustment(mapSize: mapSize) @@ -23,13 +23,13 @@ func delta(zoom: Int, mapSize: CGSize) -> Double { } private func adjustment(mapSize: CGSize) -> Double { - lerp(at: min(mapSize.width, mapSize.height), in: adjustments) + lerp(at: max(mapSize.width, mapSize.height), in: adjustments) } private let adjustments = NonEmptyArray([ - (375.0, 0.65), // iPhone SE (3rd gen) width - (430.0, 0.8), // iPhone 15 Pro Max width - (1024.0, 1.5), // iPad Pro 13' width + (667.0, 0.65), // iPhone SE (3rd gen) height + (932.0, 0.8), // iPhone 15 Pro Max height + (1366.0, 1.5), // iPad Pro 13" height ].map { InterpolationPoint($0, $1) })! extension Double: Interpolatable { diff --git a/RewindTests/LocalClusteringTests.swift b/RewindTests/LocalClusteringTests.swift index bad4342..8dff046 100644 --- a/RewindTests/LocalClusteringTests.swift +++ b/RewindTests/LocalClusteringTests.swift @@ -416,6 +416,7 @@ private func emptyState() -> MapState { controls: MapState.ControlsState( expandedItems: [], minimization: .normal, + size: .zero, ), ) }