diff --git a/README.md b/README.md index 2b28fd791..623b65e12 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ const styles = StyleSheet.create({ - [Camera](docs/Camera.md) - [UserLocation](docs/UserLocation.md) - [Images](docs/Images.md) +- [Image](docs/Image.md) ### Sources diff --git a/__tests__/interface.test.js b/__tests__/interface.test.js index 9a5a07e0f..723e2c0c9 100644 --- a/__tests__/interface.test.js +++ b/__tests__/interface.test.js @@ -40,6 +40,7 @@ describe('Public Interface', () => { 'ImageSource', 'RasterDemSource', 'Images', + 'Image', // constants 'UserTrackingModes', diff --git a/docs/Image.md b/docs/Image.md new file mode 100644 index 000000000..0d0fbe57d --- /dev/null +++ b/docs/Image.md @@ -0,0 +1,14 @@ + +# + + +## props +| Prop | Type | Default | Required | Description | +| ---- | :-- | :----- | :------ | :---------- | +| name | `string` | `none` | `true` | Image name | +| sdf | `boolean` | `none` | `false` | Make image an sdf optional - see [SDF icons](https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/) | +| stretchX | `Array` | `none` | `false` | stretch along x axis - optional | +| stretchY | `Array` | `none` | `false` | stretch along y axis - optional | +| children | `ReactElement` | `none` | `true` | Single react native view generating the image | + + diff --git a/docs/Images.md b/docs/Images.md index aed9761a0..5dd7cdf78 100644 --- a/docs/Images.md +++ b/docs/Images.md @@ -10,6 +10,6 @@ Images defines the images used in Symbol etc. layers. | nativeAssetImages | `Array` | `none` | `false` | If you have an asset under Image.xcassets on iOS and the drawables directory on android
you can specify an array of string names with assets as the key `['pin']`.
Additionally object with keys sdf, and strechX, strechY is supported for [SDF icons](https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/) | | onImageMissing | `func` | `none` | `false` | Gets called when a Layer is trying to render an image whose key is not present in
any of the `Images` component of the Map.
*signature:*`(imageKey:string) => void` | | id | `string` | `none` | `false` | FIX ME NO DESCRIPTION | -| children | `ReactReactElement` | `none` | `false` | FIX ME NO DESCRIPTION | +| children | `ReactElement \| Array> \| never` | `none` | `false` | FIX ME NO DESCRIPTION | diff --git a/docs/docs.json b/docs/docs.json index 3515d03b9..edddb8755 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1916,6 +1916,50 @@ } ] }, + "Image": { + "description": "", + "displayName": "Image", + "methods": [], + "props": [ + { + "name": "name", + "required": true, + "type": "string", + "default": "none", + "description": "Image name" + }, + { + "name": "sdf", + "required": false, + "type": "boolean", + "default": "none", + "description": "Make image an sdf optional - see [SDF icons](https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/)" + }, + { + "name": "stretchX", + "required": false, + "type": "Array", + "default": "none", + "description": "stretch along x axis - optional" + }, + { + "name": "stretchY", + "required": false, + "type": "Array", + "default": "none", + "description": "stretch along y axis - optional" + }, + { + "name": "children", + "required": true, + "type": "ReactElement", + "default": "none", + "description": "Single react native view generating the image" + } + ], + "fileNameWithExt": "Image.tsx", + "name": "Image" + }, "ImageSource": { "description": "ImageSource is a content source that is used for a georeferenced raster image to be shown on the map.\nThe georeferenced image scales and rotates as the user zooms and rotates the map", "displayName": "ImageSource", @@ -2011,7 +2055,7 @@ { "name": "children", "required": false, - "type": "ReactReactElement", + "type": "ReactElement \\| Array> \\| never", "default": "none", "description": "FIX ME NO DESCRIPTION" } diff --git a/example/src/examples/SymbolCircleLayer/SdfIcons.tsx b/example/src/examples/SymbolCircleLayer/SdfIcons.tsx index 1eb59c0aa..29860ca86 100644 --- a/example/src/examples/SymbolCircleLayer/SdfIcons.tsx +++ b/example/src/examples/SymbolCircleLayer/SdfIcons.tsx @@ -1,9 +1,9 @@ import React from 'react'; +import { View } from 'react-native'; import Mapbox from '@rnmapbox/maps'; import sheet from '../../styles/sheet'; import exampleIcon from '../../assets/example.png'; -import pinIcon from '../../assets/pin.png'; import { BaseExampleProps } from '../common/BaseExamplePropTypes'; import Page from '../common/Page'; @@ -11,7 +11,7 @@ const styles = { icon: { iconImage: ['get', 'icon'], - iconColor: '#f0c', + iconColor: ['get', 'color'], iconSize: [ 'match', @@ -32,7 +32,8 @@ const featureCollection: GeoJSON.FeatureCollection = { type: 'Feature', id: '9d10456e-bdda-4aa9-9269-04c1667d4552', properties: { - icon: 'example', + icon: 'rn-image', + color: '#f0c', }, geometry: { type: 'Point', @@ -43,7 +44,8 @@ const featureCollection: GeoJSON.FeatureCollection = { type: 'Feature', id: '9d10456e-bdda-4aa9-9269-04c1667d4552', properties: { - icon: 'airport-15', + icon: 'rn-image', + color: '#0fc', }, geometry: { type: 'Point', @@ -54,7 +56,8 @@ const featureCollection: GeoJSON.FeatureCollection = { type: 'Feature', id: '9d10456e-bdda-4aa9-9269-04c1667d4552', properties: { - icon: 'pin', + icon: 'rn-image', + color: '#cf0', }, geometry: { type: 'Point', @@ -65,7 +68,8 @@ const featureCollection: GeoJSON.FeatureCollection = { type: 'Feature', id: '9d10456e-bdda-4aa9-9269-04c1667d4553', properties: { - icon: 'pin3', + icon: 'rn-image', + color: '#c00', }, geometry: { type: 'Point', @@ -96,11 +100,20 @@ class SdfIcons extends React.PureComponent { nativeAssetImages={[{ name: 'pin', sdf: true }]} images={images} onImageMissing={(imageKey: string) => - this.setState({ - images: { ...this.state.images, [imageKey]: pinIcon }, - }) + console.log('=> on image missing', imageKey) } - /> + > + + + + diff --git a/index.d.ts b/index.d.ts index aefce8dcf..e99b11a6c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -47,7 +47,8 @@ import { type UserTrackingMode as _UserTrackingMode, type UserTrackingModeChangeCallback as _UserTrackingModeChangeCallback, } from './javascript/components/Camera'; -import { Images as _Images } from './javascript/components/Images'; +import _Images from './javascript/components/Images'; +import _Image from './javascript/components/Image'; import { MarkerView as _MarkerView } from './javascript/components/MarkerView'; import { PointAnnotation as _PointAnnotation } from './javascript/components/PointAnnotation'; import { Atmosphere as _Atmosphere } from './javascript/components/Atmosphere'; @@ -358,6 +359,8 @@ declare namespace MapboxGL { type Location = _Location; type Images = _Images; const Images = _Images; + type Image = _Image; + const Image = _Image; /** * Offline @@ -780,6 +783,7 @@ export import AnimatedPoint = MapboxGL.AnimatedPoint; export import AnimatedMapPoint = MapboxGL.AnimatedPoint; export import AnimatedShape = MapboxGL.AnimatedShape; export import Images = MapboxGL.Images; +export import Image = MapboxGL.Image; export const { offlineManager } = MapboxGL; diff --git a/ios/RCTMGL-v10/RCMTGLImage.swift b/ios/RCTMGL-v10/RCMTGLImage.swift new file mode 100644 index 000000000..a86222a16 --- /dev/null +++ b/ios/RCTMGL-v10/RCMTGLImage.swift @@ -0,0 +1,93 @@ +import MapboxMaps + +class RCTMGLImage : UIView { + @objc + var name: String = "" + + var image: UIImage? = nil + + var sdf: Bool? = nil + var stretchX: [[NSNumber]] = [] + var stretchY: [[NSNumber]] = [] + + weak var images: RCTMGLImageSetter? = nil { + didSet { + DispatchQueue.main.async { self.setImage() } + } + } + weak var bridge : RCTBridge! = nil + + var reactSubviews : [UIView] = [] + + // MARK: - subview management + + @objc open override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + reactSubviews.insert(subview, at: atIndex) + if reactSubviews.count > 1 { + Logger.log(level: .error, message: "Image supports max 1 subview") + } + if image == nil { + DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10)) { + self.setImage() + } + } + } + + @objc + open override func removeReactSubview(_ subview: UIView!) { + reactSubviews.removeAll(where: { $0 == subview }) + } + + // MARK: - view shnapshot + + func changeImage(_ image: UIImage, name: String) { + if let images = images { + let _ = images.addImage(name: name, image: image, sdf: sdf, stretchX:stretchX, stretchY:stretchY, log: "RCTMGLImage.addImage") + } + } + + func setImage() { + if let image = _createViewSnapshot() { + changeImage(image, name: name) + } + } + + func _createViewSnapshot() -> UIImage? { + let useDummyImage = false + if useDummyImage { + let size = CGSize(width: 32, height: 32) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + UIColor.darkGray.setStroke() + context.stroke(CGRect(x: 0, y:0, width: 32, height: 32)) + UIColor(red: 158/255, green: 215/255, blue: 245/255, alpha: 1).setFill() + context.fill(CGRect(x: 2, y: 2, width: 30, height: 30)) + } + return image + } + guard reactSubviews.count > 0 else { + return nil + } + return _createViewSnapshot(view: reactSubviews[0]) + } + + func _createViewSnapshot(view: UIView) -> UIImage? { + guard view.bounds.size.width > 0 && view.bounds.size.height > 0 else { + return nil + } + + let roundUp = 4 + + let adjustedSize = CGSize( + width: ((Int(view.bounds.size.width)+roundUp-1)/roundUp)*roundUp, + height: ((Int(view.bounds.size.height)+roundUp-1)/roundUp)*roundUp + ) + + let renderer = UIGraphicsImageRenderer(size: adjustedSize) + let image = renderer.image { context in + view.layer.render(in: context.cgContext) + } + return image + } +} + diff --git a/ios/RCTMGL-v10/RCMTGLImageManager.m b/ios/RCTMGL-v10/RCMTGLImageManager.m new file mode 100644 index 000000000..3caaf3615 --- /dev/null +++ b/ios/RCTMGL-v10/RCMTGLImageManager.m @@ -0,0 +1,9 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(RCTMGLImageManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(name, NSString) + +@end + diff --git a/ios/RCTMGL-v10/RCMTGLImageManager.swift b/ios/RCTMGL-v10/RCMTGLImageManager.swift new file mode 100644 index 000000000..275478b2f --- /dev/null +++ b/ios/RCTMGL-v10/RCMTGLImageManager.swift @@ -0,0 +1,14 @@ + +@objc(RCTMGLImageManager) +class RCTMGLImageManager : RCTViewManager { + @objc + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + let layer = RCTMGLImage() + layer.bridge = self.bridge + return layer + } +} diff --git a/ios/RCTMGL-v10/RCTMGLImages.swift b/ios/RCTMGL-v10/RCTMGLImages.swift index 9b7f22a5c..706062510 100644 --- a/ios/RCTMGL-v10/RCTMGLImages.swift +++ b/ios/RCTMGL-v10/RCTMGLImages.swift @@ -1,16 +1,24 @@ import MapboxMaps +protocol RCTMGLImageSetter : AnyObject { + func addImage(name: String, image: UIImage, sdf: Bool?, stretchX: [[NSNumber]], stretchY: [[NSNumber]], log: String) -> Bool +} + class RCTMGLImages : UIView, RCTMGLMapComponent { weak var bridge : RCTBridge! = nil var remoteImages : [String:String] = [:] + weak var style: Style? = nil + @objc var onImageMissing: RCTBubblingEventBlock? = nil @objc var images : [String:Any] = [:] + var imageViews: [RCTMGLImage] = [] + @objc var nativeImages: [Any] = [] { didSet { @@ -21,6 +29,23 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { typealias NativeImageInfo = (name:String, sdf: Bool, stretchX:[(from:Float, to:Float)], stretchY:[(from:Float, to:Float)]); var nativeImageInfos: [NativeImageInfo] = [] + @objc open override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + if let image = subview as? RCTMGLImage { + imageViews.insert(image, at: atIndex) + } else { + Logger.log(level:.warn, message: "RCTMGLImages children can only be RCTMGLImage, got \(optional: subview)") + } + super.insertReactSubview(subview, at: atIndex) + } + + @objc open override func removeReactSubview(_ subview: UIView!) { + if let image = subview as? RCTMGLImage { + imageViews.removeAll { $0 == image } + image.images = nil + } + super.removeReactSubview(subview) + } + // MARK: - RCTMGLMapComponent func waitForStyleLoad() -> Bool { @@ -28,14 +53,17 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { } func addToMap(_ map: RCTMGLMapView, style: Style) { + self.style = style map.images.append(self) map.setupEvents() self.addNativeImages(style: style, nativeImages: nativeImageInfos) self.addRemoteImages(style: style, remoteImages: images) + self.addImageViews(style: style, imageViews: imageViews) } func removeFromMap(_ map: RCTMGLMapView) { + self.style = nil // v10todo } @@ -63,6 +91,12 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { } } + private func addImageViews(style: Style, imageViews: [RCTMGLImage]) { + imageViews.forEach { imageView in + imageView.images = self + } + } + public func addMissingImageToStyle(style: Style, imageName: String) -> Bool { if let nativeImage = nativeImageInfos.first(where: { $0.name == imageName }) { addNativeImages(style: style, nativeImages: [nativeImage]) @@ -84,6 +118,16 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { } } + func convert(stretch: [[NSNumber]]) -> [(from: Float, to: Float)] { + return stretch.map{ pair in + return (from: pair[0].floatValue, to: pair[1].floatValue) + } + } + + func convert(stretch: [(from: Float, to: Float)]) -> [ImageStretches] { + return stretch.map { v in ImageStretches(first: v.from, second: v.to) } + } + func decodeImage(_ imageNameOrInfo: Any) -> NativeImageInfo? { if let imageName = imageNameOrInfo as? String { return (name: imageName, sdf: false, stretchX:[],stretchY:[]) @@ -100,18 +144,14 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { } if let stretchXV = imageInfo["stretchX"] as? [[NSNumber]] { - stretchX = stretchXV.map{ pair in - return (from: pair[0].floatValue, to: pair[1].floatValue) - } + stretchX = convert(stretch: stretchXV) } if let stretchYV = imageInfo["stretchY"] as? [[NSNumber]] { - stretchY = stretchYV.map{ pair in - return (from: pair[0].floatValue, to: pair[1].floatValue) - } + stretchY = convert(stretch: stretchYV) } - return (name: name, sdf: sdf, stretchX:stretchX,stretchY:stretchY) + return (name: name, sdf: sdf, stretchX: stretchX, stretchY: stretchY) } else { Logger.log(level: .warn, message: "RCTMGLImage.nativeImage, unexpected image: \(imageNameOrInfo)") return nil @@ -125,8 +165,9 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { if let image = UIImage(named: imageName) { logged("RCTMGLImage.addNativeImage: \(imageName)") { try style.addImage(image, id: imageName, sdf: imageInfo.sdf, - stretchX: imageInfo.stretchX.map { v in ImageStretches(first: v.from, second: v.to) }, - stretchY: imageInfo.stretchY.map { v in ImageStretches(first: v.from, second: v.to) } ) + stretchX: convert(stretch: imageInfo.stretchX), + stretchY: convert(stretch: imageInfo.stretchY) + ) } } else { Logger.log(level:.error, message: "Cannot find nativeImage named \(imageName)") @@ -141,5 +182,23 @@ class RCTMGLImages : UIView, RCTMGLMapComponent { UIGraphicsEndImageContext() return result }() +} +extension RCTMGLImages : RCTMGLImageSetter { + func addImage(name: String, image: UIImage, sdf: Bool?, stretchX: [[NSNumber]], stretchY: [[NSNumber]], log: String) -> Bool + { + return logged("\(log).addImage") { + if let style = style { + try style.addImage(image, + id:name, + sdf: sdf ?? false, + stretchX: convert(stretch: convert(stretch: stretchX)), + stretchY: convert(stretch: convert(stretch: stretchY)) + ) + return true + } else { + return false + } + } ?? false + } } diff --git a/javascript/components/Image.tsx b/javascript/components/Image.tsx new file mode 100644 index 000000000..2e77624b0 --- /dev/null +++ b/javascript/components/Image.tsx @@ -0,0 +1,55 @@ +import React, { memo, forwardRef, ReactElement } from 'react'; +import { requireNativeComponent } from 'react-native'; + +interface Props { + /** Image name */ + name: string; + + /** Make image an sdf optional - see [SDF icons](https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/) */ + sdf?: boolean; + + /** stretch along x axis - optional */ + stretchX?: [number, number][]; + + /** stretch along y axis - optional */ + stretchY?: [number, number][]; + + /** Single react native view generating the image */ + children: ReactElement; +} + +interface Ref { + refresh: () => void; +} + +const Image = memo( + forwardRef(function Image( + { name, sdf, stretchX, stretchY, children }: Props, + ref: React.ForwardedRef, + ) { + const nativeProps = { + name, + sdf, + stretchX, + stretchY, + children, + }; + return ; + }), +); + +interface NativeProps { + name: string; + children: ReactElement; + sdf?: boolean; + stretchX?: [number, number][]; + stretchY?: [number, number][]; +} + +export const NATIVE_MODULE_NAME = 'RCTMGLImage'; + +const RCTMGLImage = requireNativeComponent(NATIVE_MODULE_NAME); + +Image.displayName = 'Image'; + +export default Image; diff --git a/javascript/components/Images.tsx b/javascript/components/Images.tsx index b261f58f3..6d74c5b2f 100644 --- a/javascript/components/Images.tsx +++ b/javascript/components/Images.tsx @@ -1,8 +1,9 @@ -import React from 'react'; -import { requireNativeComponent, Image } from 'react-native'; +import React, { ReactNode, ReactElement } from 'react'; +import { requireNativeComponent, Image as RNImage } from 'react-native'; import { ImageSourcePropType, ImageResolvedAssetSource } from 'react-native'; import { ShapeSource } from './ShapeSource'; +import Image from './Image'; export const NATIVE_MODULE_NAME = 'RCTMGLImages'; @@ -23,15 +24,23 @@ function _isUrlOrPath(value: string | ImageSourcePropType): value is string { ); } +type TypedReactNode = ReactElement | Array> | never; + type NativeImage = | string | { name: string; sdf?: boolean; - strechX: [number, number][]; - streacY: [number, number][]; + stretchX?: [number, number][]; + stretchY?: [number, number][]; }; +const isChildAnImage = ( + child: ReactNode, +): child is React.ReactElement => { + return React.isValidElement(child) && child.type === Image; +}; + interface Props { /** * Specifies the external images in key-value pairs required for the shape source. @@ -54,7 +63,7 @@ interface Props { onImageMissing?: (imageKey: string) => void; id?: string; - children?: React.ReactElement; + children?: TypedReactNode; } /** @@ -85,7 +94,7 @@ class Images extends React.Component { } else if (_isUrlOrPath(value)) { images[imageName] = value; } else { - const res = Image.resolveAssetSource(value); + const res = RNImage.resolveAssetSource(value); if (res && res.uri) { images[imageName] = res; } @@ -93,6 +102,20 @@ class Images extends React.Component { } } + const { children } = this.props; + if (children) { + const childrenWithWrongType = React.Children.toArray(children).find( + (child) => !isChildAnImage(child), + ); + if (childrenWithWrongType) { + console.error( + `Images component on accepts Image a children passed in: ${ + (childrenWithWrongType as any).type || 'n/a' + }`, + ); + } + } + if (this.props.nativeAssetImages) { nativeImages = this.props.nativeAssetImages; } diff --git a/javascript/index.js b/javascript/index.js index 966d98c87..b2cdf3091 100644 --- a/javascript/index.js +++ b/javascript/index.js @@ -14,6 +14,7 @@ import RasterSource from './components/RasterSource'; import RasterDemSource from './components/RasterDemSource'; import ImageSource from './components/ImageSource'; import Images from './components/Images'; +import Image from './components/Image'; import FillLayer from './components/FillLayer'; import FillExtrusionLayer from './components/FillExtrusionLayer'; import HeatmapLayer from './components/HeatmapLayer'; @@ -79,6 +80,7 @@ MapboxGL.ShapeSource = ShapeSource; MapboxGL.RasterSource = RasterSource; MapboxGL.ImageSource = ImageSource; MapboxGL.Images = Images; +MapboxGL.Image = Image; MapboxGL.RasterDemSource = RasterDemSource; // layers @@ -129,6 +131,7 @@ export { RasterDemSource, ImageSource, Images, + Image, FillLayer, FillExtrusionLayer, HeatmapLayer,