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,
),
)
}