feat(core): add sys:// support for SF Symbol usage on images with effects (#10555)

This commit is contained in:
Eduardo Speroni
2024-06-26 19:34:24 -03:00
committed by GitHub
parent 84e1a67d6d
commit d678915234
15 changed files with 269 additions and 13 deletions

View File

@ -1,4 +1,4 @@
import { Observable, EventData, Page, ImageSource, knownFolders, path } from '@nativescript/core';
import { Observable, EventData, Page, ImageSource, knownFolders, path, ImageSymbolEffects } from '@nativescript/core';
import { create, ImagePickerMediaType } from '@nativescript/imagepicker';
let page: Page;
@ -10,6 +10,10 @@ export function navigatingTo(args: EventData) {
export class DemoModel extends Observable {
addingPhoto = false;
symbolWiggleEffect: ImageSymbolEffects.Wiggle;
symbolBounceEffect: ImageSymbolEffects.Bounce;
symbolBreathEffect: ImageSymbolEffects.Breathe;
symbolRotateEffect: ImageSymbolEffects.Rotate;
pickImage() {
const context = create({

View File

@ -5,9 +5,27 @@
</Page.actionBar>
<StackLayout class="p-20">
<Label text="Test Memory leaks with image picking and saving to device. Best to profile from platform IDE like Xcode." textWrap="true" />
<Button text="Pick and Save Image" tap="{{ pickImage }}" />
<ios>
<!-- SF Symbols with Effects -->
<ContentView height="1" width="100%" backgroundColor="#efefef" margin="10"></ContentView>
<GridLayout rows="auto,auto,auto" columns="*,*">
<Image src="sys://photo.on.rectangle.angled" width="100" tintColor="green" symbolEffect="{{symbolWiggleEffect}}" padding="8"/>
<Image col="1" src="sys://steeringwheel.and.hands" width="100" tintColor="black" symbolEffect="{{symbolWiggleEffect}}" padding="8" />
<Image row="1" src="sys://airpods.pro.chargingcase.wireless.radiowaves.left.and.right.fill" width="100" symbolEffect="{{symbolBounceEffect}}" padding="8" />
<Image row="1" col="1" src="sys://lungs.fill" width="100" symbolEffect="{{symbolBreathEffect}}" padding="8" />
<Image row="2" src="sys://clock.arrow.trianglehead.2.counterclockwise.rotate.90" width="100" symbolEffect="{{symbolRotateEffect}}" padding="8" />
<Image row="2" col="1" src="sys://square.and.arrow.up" width="100" symbolEffect="{{symbolWiggleEffect}}" padding="8" />
</GridLayout>
</ios>
</StackLayout>
</Page>

View File

@ -149,6 +149,14 @@ export class ImageSource implements ImageSourceDefinition {
return ImageSource.fromFileSync(path);
}
static fromSystemImageSync(name: string): ImageSource {
return ImageSource.fromResourceSync(name);
}
static fromSystemImage(name: string): Promise<ImageSource> {
return ImageSource.fromResource(name);
}
static fromDataSync(data: any): ImageSource {
const bitmap = android.graphics.BitmapFactory.decodeStream(data);
@ -335,7 +343,7 @@ export class ImageSource implements ImageSourceDefinition {
reject();
}
},
})
}),
);
});
}
@ -375,7 +383,7 @@ export class ImageSource implements ImageSourceDefinition {
reject();
}
},
})
}),
);
});
}
@ -404,7 +412,7 @@ export class ImageSource implements ImageSourceDefinition {
reject();
}
},
})
}),
);
});
}

View File

@ -54,6 +54,18 @@ export class ImageSource {
*/
static fromResource(name: string): Promise<ImageSource>;
/**
* Loads this instance from the specified system image name.
* @param name the name of the system image
*/
static fromSystemImageSync(name: string): ImageSource;
/**
* Loads this instance from the specified system image name asynchronously.
* @param name the name of the system image
*/
static fromSystemImage(name: string): Promise<ImageSource>;
/**
* Loads this instance from the specified file.
* @param path The location of the file on the file system.

View File

@ -8,7 +8,7 @@ import { Trace } from '../trace';
// Types.
import { path as fsPath, knownFolders } from '../file-system';
import { isFileOrResourcePath, RESOURCE_PREFIX, layout, releaseNativeObject } from '../utils';
import { isFileOrResourcePath, RESOURCE_PREFIX, layout, releaseNativeObject, SYSTEM_PREFIX } from '../utils';
import { getScaledDimensions } from './image-source-common';
@ -73,6 +73,27 @@ export class ImageSource implements ImageSourceDefinition {
return http.getImage(url);
}
static fromSystemImageSync(name: string): ImageSource {
const image = UIImage.systemImageNamed(name);
return image ? new ImageSource(image) : null;
}
static fromSystemImage(name: string): Promise<ImageSource> {
return new Promise<ImageSource>((resolve, reject) => {
try {
const image = UIImage.systemImageNamed(name);
if (image) {
resolve(new ImageSource(image));
} else {
reject(new Error(`Failed to load system icon with name: ${name}`));
}
} catch (ex) {
reject(ex);
}
});
}
static fromResourceSync(name: string): ImageSource {
const nativeSource = (<any>UIImage).tns_safeImageNamed(name) || (<any>UIImage).tns_safeImageNamed(`${name}.jpg`);
@ -126,7 +147,10 @@ export class ImageSource implements ImageSourceDefinition {
}
if (path.indexOf(RESOURCE_PREFIX) === 0) {
return ImageSource.fromResourceSync(path.substr(RESOURCE_PREFIX.length));
return ImageSource.fromResourceSync(path.slice(RESOURCE_PREFIX.length));
}
if (path.indexOf(SYSTEM_PREFIX) === 0) {
return ImageSource.fromSystemImageSync(path.slice(SYSTEM_PREFIX.length));
}
return ImageSource.fromFileSync(path);

View File

@ -2,6 +2,7 @@
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!CFNetwork.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!CoreText.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!Darwin.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!Symbols.d.ts" />
/// <reference path="../types-android/src/lib/android-29.d.ts" />
/// <reference path="./platforms/ios/typings/objc!MaterialComponents.d.ts" />
/// <reference path="./platforms/ios/typings/objc!NativeScriptUtils.d.ts" />

View File

@ -4,12 +4,13 @@ import { booleanConverter } from '../core/view-base';
import { CoreTypes } from '../../core-types';
import { ImageAsset } from '../../image-asset';
import { ImageSource } from '../../image-source';
import { isDataURI, isFontIconURI, isFileOrResourcePath, RESOURCE_PREFIX } from '../../utils';
import { isDataURI, isFontIconURI, isFileOrResourcePath, RESOURCE_PREFIX, SYSTEM_PREFIX } from '../../utils';
import { Color } from '../../color';
import { Style } from '../styling/style';
import { Length } from '../styling/style-properties';
import { Property, InheritedCssProperty } from '../core/properties';
import { Trace } from '../../trace';
import { ImageSymbolEffect, ImageSymbolEffects } from './symbol-effects';
@CSSType('Image')
export abstract class ImageBase extends View implements ImageDefinition {
@ -75,13 +76,21 @@ export abstract class ImageBase extends View implements ImageDefinition {
}
} else if (isFileOrResourcePath(value)) {
if (value.indexOf(RESOURCE_PREFIX) === 0) {
const resPath = value.substr(RESOURCE_PREFIX.length);
const resPath = value.slice(RESOURCE_PREFIX.length);
if (sync) {
imageLoaded(ImageSource.fromResourceSync(resPath));
} else {
this.imageSource = null;
ImageSource.fromResource(resPath).then(imageLoaded);
}
} else if (value.indexOf(SYSTEM_PREFIX) === 0) {
const sysPath = value.slice(SYSTEM_PREFIX.length);
if (sync) {
imageLoaded(ImageSource.fromSystemImageSync(sysPath));
} else {
this.imageSource = null;
ImageSource.fromSystemImage(sysPath).then(imageLoaded);
}
} else {
if (sync) {
imageLoaded(ImageSource.fromFileSync(value));
@ -178,3 +187,13 @@ export const decodeWidthProperty = new Property<ImageBase, CoreTypes.LengthType>
valueConverter: Length.parse,
});
decodeWidthProperty.register(ImageBase);
/**
* iOS only
*/
export const iosSymbolEffectProperty = new Property<ImageBase, ImageSymbolEffect | ImageSymbolEffects>({
name: 'iosSymbolEffect',
});
iosSymbolEffectProperty.register(ImageBase);
export { ImageSymbolEffect, ImageSymbolEffects };

View File

@ -6,6 +6,7 @@ import { Color } from '../../color';
import { Property, InheritedCssProperty } from '../core/properties';
import { CoreTypes } from '../../core-types';
export { ImageSymbolEffect, ImageSymbolEffects } from './image-common';
/**
* Represents a class that provides functionality for loading and streching image(s).
*/

View File

@ -1,9 +1,10 @@
import { ImageBase, stretchProperty, imageSourceProperty, tintColorProperty, srcProperty } from './image-common';
import { ImageBase, stretchProperty, imageSourceProperty, tintColorProperty, srcProperty, iosSymbolEffectProperty, ImageSymbolEffect, ImageSymbolEffects } from './image-common';
import { ImageSource } from '../../image-source';
import { ImageAsset } from '../../image-asset';
import { Color } from '../../color';
import { Trace } from '../../trace';
import { layout, queueGC } from '../../utils';
import { SDK_VERSION } from '../../utils/constants';
export * from './image-common';
@ -194,4 +195,16 @@ export class Image extends ImageBase {
[srcProperty.setNative](value: string | ImageSource | ImageAsset) {
this._createImageSourceFromSrc(value);
}
[iosSymbolEffectProperty.setNative](value: ImageSymbolEffect | ImageSymbolEffects) {
if (SDK_VERSION < 17) {
return;
}
const symbol = typeof value === 'string' ? ImageSymbolEffect.fromSymbol(value) : value;
if (symbol && symbol.effect) {
this.nativeViewProtected.addSymbolEffectOptionsAnimatedCompletion(symbol.effect, symbol.options || NSSymbolEffectOptions.optionsWithRepeating(), true, symbol.completion || null);
} else {
this.nativeViewProtected.removeAllSymbolEffects();
}
}
}

View File

@ -0,0 +1,37 @@
export enum ImageSymbolEffects {
Appear = 'appear',
AppearUp = 'appearUp',
AppearDown = 'appearDown',
Bounce = 'bounce',
BounceUp = 'bounceUp',
BounceDown = 'bounceDown',
Disappear = 'disappear',
DisappearDown = 'disappearDown',
DisappearUp = 'disappearUp',
Pulse = 'pulse',
Scale = 'scale',
ScaleDown = 'scaleDown',
ScaleUp = 'scaleUp',
VariableColor = 'variableColor',
Breathe = 'breathe',
BreathePlain = 'breathePlain',
BreathePulse = 'breathePulse',
Rotate = 'rotate',
RotateClockwise = 'rotateClockwise',
RotateCounterClockwise = 'rotateCounterClockwise',
Wiggle = 'wiggle',
WiggleBackward = 'wiggleBackward',
WiggleClockwise = 'wiggleClockwise',
WiggleCounterClockwise = 'wiggleCounterClockwise',
WiggleDown = 'wiggleDown',
WiggleForward = 'wiggleForward',
WiggleUp = 'wiggleUp',
WiggleLeft = 'wiggleLeft',
WiggleRight = 'wiggleRight',
}
export class ImageSymbolEffectCommon {
effect?: NSSymbolEffect;
options?: NSSymbolEffectOptions;
completion?: (context: UISymbolEffectCompletionContext) => void;
}

View File

@ -0,0 +1,9 @@
import { ImageSymbolEffectCommon, ImageSymbolEffects } from './symbol-effects-common';
import type { ImageSymbolEffect as ImageSymbolEffectDefinition } from './symbol-effects.d.ts';
export { ImageSymbolEffects };
export const ImageSymbolEffect: typeof ImageSymbolEffectDefinition = class ImageSymbolEffect extends ImageSymbolEffectCommon implements ImageSymbolEffectDefinition {
static fromSymbol(symbol: string): ImageSymbolEffectDefinition {
return new ImageSymbolEffect();
}
};

View File

@ -0,0 +1,13 @@
export { ImageSymbolEffects } from './symbol-effects-common';
/**
* iOS only
* Symbol effects: https://developer.apple.com/documentation/symbols?language=objc
*/
export class ImageSymbolEffect {
effect?: NSSymbolEffect;
options?: NSSymbolEffectOptions;
completion?: (context: UISymbolEffectCompletionContext) => void;
constructor(symbol: NSSymbolEffect);
static fromSymbol(symbol: string): ImageSymbolEffect | null;
}

View File

@ -0,0 +1,95 @@
import { SDK_VERSION } from '../../utils/constants';
import { ImageSymbolEffectCommon, ImageSymbolEffects } from './symbol-effects-common';
import type { ImageSymbolEffect as ImageSymbolEffectDefinition } from './symbol-effects.d.ts';
export const ImageSymbolEffect: typeof ImageSymbolEffectDefinition = class ImageSymbolEffect extends ImageSymbolEffectCommon implements ImageSymbolEffectDefinition {
constructor(symbol: NSSymbolEffect) {
super();
this.effect = symbol;
}
static fromSymbol(symbol: string): ImageSymbolEffectDefinition | null {
if (SDK_VERSION < 17) {
return null;
}
switch (symbol) {
case ImageSymbolEffects.Appear:
return new ImageSymbolEffect(NSSymbolAppearEffect.effect());
case ImageSymbolEffects.AppearUp:
return new ImageSymbolEffect(NSSymbolAppearEffect.appearUpEffect());
case ImageSymbolEffects.AppearDown:
return new ImageSymbolEffect(NSSymbolAppearEffect.appearDownEffect());
case ImageSymbolEffects.Bounce:
return new ImageSymbolEffect(NSSymbolBounceEffect.effect());
case ImageSymbolEffects.BounceUp:
return new ImageSymbolEffect(NSSymbolBounceEffect.bounceUpEffect());
case ImageSymbolEffects.BounceDown:
return new ImageSymbolEffect(NSSymbolBounceEffect.bounceDownEffect());
case ImageSymbolEffects.Disappear:
return new ImageSymbolEffect(NSSymbolDisappearEffect.effect());
case ImageSymbolEffects.DisappearDown:
return new ImageSymbolEffect(NSSymbolDisappearEffect.disappearDownEffect());
case ImageSymbolEffects.DisappearUp:
return new ImageSymbolEffect(NSSymbolDisappearEffect.disappearUpEffect());
case ImageSymbolEffects.Pulse:
return new ImageSymbolEffect(NSSymbolPulseEffect.effect());
case ImageSymbolEffects.Scale:
return new ImageSymbolEffect(NSSymbolScaleEffect.effect());
case ImageSymbolEffects.ScaleDown:
return new ImageSymbolEffect(NSSymbolScaleEffect.scaleDownEffect());
case ImageSymbolEffects.ScaleUp:
return new ImageSymbolEffect(NSSymbolScaleEffect.scaleUpEffect());
case ImageSymbolEffects.VariableColor:
return new ImageSymbolEffect(NSSymbolVariableColorEffect.effect());
}
if (SDK_VERSION < 18) {
return null;
}
// TODO: remove ts-expect-error once we bump the types package
switch (symbol) {
case ImageSymbolEffects.Breathe:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolBreatheEffect.effect());
case ImageSymbolEffects.BreathePlain:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolBreatheEffect.breathePlainEffect());
case ImageSymbolEffects.Rotate:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolRotateEffect.effect());
case ImageSymbolEffects.RotateClockwise:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolRotateEffect.rotateClockwiseEffect());
case ImageSymbolEffects.RotateCounterClockwise:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolRotateEffect.rotateCounterClockwiseEffect());
case ImageSymbolEffects.Wiggle:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.effect());
case ImageSymbolEffects.WiggleBackward:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleBackwardEffect());
case ImageSymbolEffects.WiggleClockwise:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleClockwiseEffect());
case ImageSymbolEffects.WiggleCounterClockwise:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleCounterClockwiseEffect());
case ImageSymbolEffects.WiggleDown:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleDownEffect());
case ImageSymbolEffects.WiggleForward:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleForwardEffect());
case ImageSymbolEffects.WiggleUp:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleUpEffect());
case ImageSymbolEffects.WiggleLeft:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleLeftEffect());
case ImageSymbolEffects.WiggleRight:
// @ts-expect-error added on iOS 18
return new ImageSymbolEffect(NSSymbolWiggleEffect.wiggleRightEffect());
}
return null;
}
};

View File

@ -31,7 +31,7 @@ export { GesturesObserver, TouchAction, GestureTypes, GestureStateTypes, SwipeDi
export type { GestureEventData, GestureEventDataWithState, TapGestureEventData, PanGestureEventData, PinchGestureEventData, RotationGestureEventData, SwipeGestureEventData, TouchGestureEventData, TouchAnimationOptions, VisionHoverOptions } from './gestures';
export { HtmlView } from './html-view';
export { Image } from './image';
export { Image, ImageSymbolEffect, ImageSymbolEffects } from './image';
export { Cache as ImageCache } from './image-cache';
export type { DownloadError, DownloadRequest, DownloadedData } from './image-cache';
export { Label } from './label';

View File

@ -8,6 +8,7 @@ export * from './mainthread-helper';
export * from './macrotask-scheduler';
export const RESOURCE_PREFIX = 'res://';
export const SYSTEM_PREFIX = 'sys://';
export const FILE_PREFIX = 'file:///';
export function escapeRegexSymbols(source: string): string {
@ -75,7 +76,8 @@ export function isFileOrResourcePath(path: string): boolean {
return (
path.indexOf('~/') === 0 || // relative to AppRoot
path.indexOf('/') === 0 || // absolute path
path.indexOf(RESOURCE_PREFIX) === 0
path.indexOf(RESOURCE_PREFIX) === 0 ||
path.indexOf(SYSTEM_PREFIX) === 0
); // resource
}
@ -215,7 +217,7 @@ export function queueGC(delay = 900, useThrottle?: boolean) {
if (!throttledGC.get(delay)) {
throttledGC.set(
delay,
throttle(() => GC(), delay)
throttle(() => GC(), delay),
);
}
throttledGC.get(delay)();
@ -226,7 +228,7 @@ export function queueGC(delay = 900, useThrottle?: boolean) {
if (!debouncedGC.get(delay)) {
debouncedGC.set(
delay,
debounce(() => GC(), delay)
debounce(() => GC(), delay),
);
}
debouncedGC.get(delay)();