diff --git a/app.xcodeproj/project.pbxproj b/app.xcodeproj/project.pbxproj index 3ec2d02..c83e7d7 100644 --- a/app.xcodeproj/project.pbxproj +++ b/app.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 4B96E77B2608C14C0057C366 /* Arcdegree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B96E7792608C14C0057C366 /* Arcdegree.swift */; }; 4B96EBDC2608C5780057C366 /* View+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B96EBDA2608C5780057C366 /* View+Helpers.swift */; }; 4B96EBDD2608C5780057C366 /* Channel+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B96EBDB2608C5780057C366 /* Channel+Helpers.swift */; }; + 4B96D147260224AB0057C366 /* CustomZoomControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B96D146260224AB0057C366 /* CustomZoomControl.swift */; }; 4BADDFCC2528016600FBF589 /* SuggestResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BADDFCB2528016600FBF589 /* SuggestResultView.swift */; }; 4BADDFCF252801BD00FBF589 /* SuggestResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BADDFCE252801BC00FBF589 /* SuggestResultViewModel.swift */; }; 4BADDFD2252802A500FBF589 /* SearchResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BADDFD1252802A500FBF589 /* SearchResultView.swift */; }; @@ -98,6 +99,7 @@ 4B96E7792608C14C0057C366 /* Arcdegree.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Arcdegree.swift; sourceTree = ""; }; 4B96EBDA2608C5780057C366 /* View+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Helpers.swift"; sourceTree = ""; }; 4B96EBDB2608C5780057C366 /* Channel+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Channel+Helpers.swift"; sourceTree = ""; }; + 4B96D146260224AB0057C366 /* CustomZoomControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomZoomControl.swift; sourceTree = ""; }; 4BADDFCB2528016600FBF589 /* SuggestResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestResultView.swift; sourceTree = ""; }; 4BADDFCE252801BC00FBF589 /* SuggestResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestResultViewModel.swift; sourceTree = ""; }; 4BADDFD1252802A500FBF589 /* SearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultView.swift; sourceTree = ""; }; @@ -273,6 +275,7 @@ children = ( 4BBE7E1F25264F2B00D7EBDB /* MapView.swift */, 0991909525B96E1C00F4235B /* MapControl.swift */, + 4B96D146260224AB0057C366 /* CustomZoomControl.swift */, ); path = Map; sourceTree = ""; @@ -423,6 +426,7 @@ 4BADDFD2252802A500FBF589 /* SearchResultView.swift in Sources */, 4B96D1902603682B0057C366 /* StylePickerViewModel.swift in Sources */, 0991909625B96E1C00F4235B /* MapControl.swift in Sources */, + 4B96D147260224AB0057C366 /* CustomZoomControl.swift in Sources */, 4B4291652527A49C006E74BE /* SuggestView.swift in Sources */, B709914F25FA6B6F00B2F1A5 /* RenderedObjectInfo+Helpers.swift in Sources */, 4B96D18E260348C10057C366 /* StylePickerView.swift in Sources */, diff --git a/app/Container.swift b/app/Container.swift index 10a9980..fb724ea 100644 --- a/app/Container.swift +++ b/app/Container.swift @@ -40,7 +40,7 @@ final class Container { return sdk.sourceFactory }, routeEditorSourceFactory: { [sdk = self.sdk] routeEditor in - return RouteEditorSource(context: sdk.context, routeEditor: routeEditor) + return sdk.sourceFactory.createRouteEditorSource(routeEditor: routeEditor) }, routeEditorFactory: { [sdk = self.sdk] in return RouteEditor(context: sdk.context) @@ -51,6 +51,10 @@ final class Container { [mapFactory = self.mapFactory] in mapFactory.mapView }, + customZoomControlFactory: { + [mapFactory = self.mapFactory] in + CustomZoomControl(map: mapFactory.map) + }, mapControlFactory: self.mapFactory.mapControlFactory ) return viewFactory diff --git a/app/Map/CustomZoomControl.swift b/app/Map/CustomZoomControl.swift new file mode 100644 index 0000000..b4d276c --- /dev/null +++ b/app/Map/CustomZoomControl.swift @@ -0,0 +1,104 @@ +import UIKit +import PlatformSDK + +/// Блок управления масштабом карты. +public final class CustomZoomControl: UIControl { + public static override var requiresConstraintBasedLayout: Bool { true } + private let stack: UIStackView + + init(map: Map) { + let model = ZoomControlModel(map: map) + + let zoomIn = ZoomButton(model: model, direction: .zoomIn) + let zoomInImage = UIImage(systemName: "plus.magnifyingglass") + zoomIn.setImage(zoomInImage, for: .normal) + + let zoomOut = ZoomButton(model: model, direction: .zoomOut) + let zoomOutImage = UIImage(systemName: "minus.magnifyingglass") + zoomOut.setImage(zoomOutImage, for: .normal) + + self.stack = UIStackView(arrangedSubviews: [zoomIn, zoomOut]) + self.stack.distribution = .fillEqually + self.stack.alignment = .fill + self.stack.axis = .vertical + + super.init(frame: .zero) + + self.addSubview(self.stack) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("Use init(map:)") + } + + public override func layoutSubviews() { + super.layoutSubviews() + self.stack.frame = self.bounds + } +} + +private final class ZoomButton: UIButton { + private let model: ZoomControlModel + private let direction: ZoomControlButton + private var connection: ICancellable = NoopCancellable() + + init(model: ZoomControlModel, direction: ZoomControlButton) { + self.model = model + self.direction = direction + + super.init(frame: .zero) + + self.contentVerticalAlignment = .fill + self.contentHorizontalAlignment = .fill + + self.addTarget( + self, + action: #selector(self.startZoom), + for: .touchDown + ) + self.addTarget( + self, + action: #selector(self.stopZoom), + for: [.touchCancel, .touchUpInside, .touchUpOutside] + ) + + // Реагируем на отключение действия — выключаем кнопку. + // Такое возможно при достижении предела изменения масштаба. + self.connection = self.model.isEnabled(button: self.direction).sink { + [weak self] isEnabled in + DispatchQueue.main.async { + self?.isEnabled = isEnabled + } + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func startZoom() { + UIView.animate( + withDuration: 0.25, + delay: 0, + usingSpringWithDamping: 1, + initialSpringVelocity: 5, + animations: { + self.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + }) + self.model.setPressed(button: self.direction, value: true) + } + + @objc private func stopZoom() { + UIView.animate( + withDuration: 0.25, + delay: 0, + usingSpringWithDamping: 0.5, + initialSpringVelocity: 5, + animations: { + self.transform = CGAffineTransform.identity + }) + self.model.setPressed(button: self.direction, value: false) + } +} diff --git a/app/Root/RootView.swift b/app/Root/RootView.swift index bc9b7ab..bf91534 100644 --- a/app/Root/RootView.swift +++ b/app/Root/RootView.swift @@ -84,9 +84,7 @@ struct RootView: View { HStack { Spacer() self.viewFactory.makeZoomControl() - .frame(width: 60, height: 128) - .fixedSize() - .transformEffect(.init(scaleX: 0.8, y: 0.8)) + .frame(width: 60, height: 120) .padding(10) } } diff --git a/app/Root/RootViewFactory.swift b/app/Root/RootViewFactory.swift index 4bec3c4..d878b36 100644 --- a/app/Root/RootViewFactory.swift +++ b/app/Root/RootViewFactory.swift @@ -6,6 +6,7 @@ struct RootViewFactory { private let markerViewModel: MarkerViewModel private let routeViewModel: RouteViewModel private let mapUIViewFactory: () -> UIView & IMapView + private let customZoomControlFactory: () -> UIView private let mapControlFactory: IMapControlFactory init( @@ -13,12 +14,14 @@ struct RootViewFactory { markerViewModel: MarkerViewModel, routeViewModel: RouteViewModel, mapUIViewFactory: @escaping () -> UIView & IMapView, + customZoomControlFactory: @escaping () -> UIView, mapControlFactory: IMapControlFactory ) { self.viewModel = viewModel self.markerViewModel = markerViewModel self.routeViewModel = routeViewModel self.mapUIViewFactory = mapUIViewFactory + self.customZoomControlFactory = customZoomControlFactory self.mapControlFactory = mapControlFactory } @@ -27,7 +30,7 @@ struct RootViewFactory { } func makeZoomControl() -> some View { - MapControl(controlFactory: self.mapControlFactory.makeZoomControl) + MapControl(controlFactory: self.customZoomControlFactory) } func makeSearchView() -> some View { diff --git a/docs/ru/examples.md b/docs/ru/examples.md index f86e468..acc3f64 100644 --- a/docs/ru/examples.md +++ b/docs/ru/examples.md @@ -463,3 +463,66 @@ private func tap(point: ScreenPoint, tapRadius: ScreenDistance) { self.getRenderedObjectsCancellable = cancel } ``` + +## Создание кнопки управления масштабом + +В SDK есть готовый к использованию блок управления масштабом, создаваемый +с помощью метода `PlatformSDK.Container.mapControlFactory.makeZoomControl()`. + +Пример описывает создание собственной кнопки управления масштабом в фиксированном +направлении: `direction` указывает, будет кнопка уменьшать или увеличивать. +Ударживание кнопки в нажатом состоянии продолжает непрерывное изменение +масштаба. Когда предел будет достигнут — кнопка визуально изменит состояние. + +Из двух таких кнопок можно собрать блок свободного управления масштабом. + +Экземпляр `ZoomControlModel` можно получить с помощью функции +`PlatformSDK.createZoomControlModel(map:)`. + +``` +final class ZoomButton: UIButton { + private let model: ZoomControlModel + private let direction: ZoomControlButton + private var connection: ICancellable = NoopCancellable() + + init(model: ZoomControlModel, direction: ZoomControlButton) { + self.model = model + self.direction = direction + + super.init(frame: .zero) + + self.addTarget( + self, + action: #selector(self.startZoom), + for: .touchDown + ) + self.addTarget( + self, + action: #selector(self.stopZoom), + for: [.touchCancel, .touchUpInside, .touchUpOutside] + ) + + // Реагируем на отключение действия — выключаем кнопку. + // Такое возможно при достижении предела изменения масштаба. + self.connection = self.model.isEnabled(button: self.direction).sink { + [weak self] isEnabled in + DispatchQueue.main.async { + self?.isEnabled = isEnabled + } + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func startZoom() { + self.model.setPressed(button: self.direction, value: true) + } + + @objc private func stopZoom() { + self.model.setPressed(button: self.direction, value: false) + } +} +```