expo-splash-screen
Version:
Provides a module to allow keeping the native Splash Screen visible until you choose to hide it.
547 lines (489 loc) • 14.1 kB
text/typescript
import crypto from 'crypto';
import { Builder, Parser } from 'xml2js';
export type IBBoolean = 'YES' | 'NO' | boolean;
export type IBItem<
H extends Record<string, any>,
B extends Record<string, any[]> = { [key: string]: any },
> = {
$: H;
} & B;
export type Rect = {
key: string;
x: number;
y: number;
width: number;
height: number;
};
export type IBRect = IBItem<Rect>;
export type IBAutoresizingMask = IBItem<{
/** @example `autoresizingMask` */
key: string;
flexibleMaxX: IBBoolean;
flexibleMaxY: IBBoolean;
}>;
/** @example `<color key="textColor" systemColor="linkColor"/>` */
export type IBColor = IBItem<
{
/** @example `textColor` */
key: string;
} & (
| /** Custom color */
{
/** @example `0.86584504117670746` */
red: number;
/** @example `0.26445041990630447` */
green: number;
/** @example `0.3248577810203549` */
blue: number;
/** @example `1` */
alpha: number;
colorSpace: 'custom' | string;
customColorSpace: 'displayP3' | 'sRGB' | string;
}
/** Built-in color */
| {
systemColor: 'linkColor' | string;
}
)
>;
export type IBFontDescription = IBItem<{
/** @example `fontDescription` */
key: string;
/** Font size */
pointSize: number;
/** Custom font */
name?: 'HelveticaNeue' | string;
family?: 'Helvetica Neue' | string;
/** Built-in font */
type?: 'system' | 'boldSystem' | 'UICTFontTextStyleCallout' | 'UICTFontTextStyleBody' | string;
}>;
export type ImageContentMode = 'scaleAspectFit' | 'scaleAspectFill';
export type ConstraintAttribute = 'top' | 'bottom' | 'trailing' | 'leading' | 'centerX' | 'centerY';
export type IBImageView = IBItem<
{
id: string;
userLabel: string;
image: string;
clipsSubviews?: IBBoolean;
userInteractionEnabled: IBBoolean;
contentMode: IBContentMode;
horizontalHuggingPriority?: number;
verticalHuggingPriority?: number;
insetsLayoutMarginsFromSafeArea?: IBBoolean;
translatesAutoresizingMaskIntoConstraints?: IBBoolean;
},
{
rect: IBRect[];
}
>;
export type IBLabel = IBItem<
{
id: string;
/** The main value. */
text: string;
opaque: IBBoolean;
fixedFrame: IBBoolean;
textAlignment?: IBTextAlignment;
lineBreakMode:
| 'clip'
| 'characterWrap'
| 'wordWrap'
| 'headTruncation'
| 'middleTruncation'
| 'tailTruncation';
baselineAdjustment?: 'none' | 'alignBaselines';
adjustsFontSizeToFit: IBBoolean;
userInteractionEnabled: IBBoolean;
contentMode: IBContentMode;
horizontalHuggingPriority: number;
verticalHuggingPriority: number;
translatesAutoresizingMaskIntoConstraints?: IBBoolean;
},
{
/** @example `<rect key="frame" x="175" y="670" width="35" height="17"/>` */
rect: IBRect[];
/** @example `<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>` */
autoresizingMask?: IBAutoresizingMask[];
/** @example `<fontDescription key="fontDescription" type="system" pointSize="19"/>` */
fontDescription?: IBFontDescription[];
/** @example `<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>` */
color?: IBColor[];
nil?: IBItem<{
/** @example `textColor` `highlightedColor` */
key: string;
}>[];
}
>;
export type IBTextAlignment = 'left' | 'center' | 'right' | 'justified' | 'natural';
export type IBContentMode = string | 'left' | 'scaleAspectFill';
export type IBConstraint = IBItem<{
firstItem: string;
firstAttribute: ConstraintAttribute;
secondItem: string;
secondAttribute: ConstraintAttribute;
constant?: number;
id: string;
}>;
export type IBViewController = IBItem<
{
id: string;
placeholderIdentifier?: string;
userLabel: string;
sceneMemberID: string;
},
{
view: IBItem<
{
id: string;
key: string;
userInteractionEnabled: IBBoolean;
contentMode: string | 'scaleToFill';
insetsLayoutMarginsFromSafeArea: IBBoolean;
userLabel: string;
},
{
rect: IBRect[];
autoresizingMask: IBItem<{
key: string;
flexibleMaxX: IBBoolean;
flexibleMaxY: IBBoolean;
}>[];
subviews: IBItem<
object,
{
imageView: IBImageView[];
label: IBLabel[];
}
>[];
color: IBItem<{
key: string | 'backgroundColor';
name?: string;
systemColor?: string | 'systemBackgroundColor';
red?: string;
green?: string;
blue?: string;
alpha?: string;
colorSpace?: string;
customColorSpace?: string;
}>[];
constraints: IBItem<
object,
{
constraint: IBConstraint[];
}
>[];
viewLayoutGuide: IBItem<{
id: string;
key: string | 'safeArea';
}>[];
}
>[];
}
>;
export type IBPoint = IBItem<{
key: string | 'canvasLocation';
x: number;
y: number;
}>;
export type IBScene = IBItem<
{ sceneID: string },
{
objects: {
viewController: IBViewController[];
placeholder: IBItem<{
id: string;
placeholderIdentifier?: string;
userLabel: string;
sceneMemberID: string;
}>[];
}[];
point: IBPoint[];
}
>;
export type IBResourceImage = IBItem<{
name: string;
width: number;
height: number;
}>;
export type IBResourceNamedColor = IBItem<{
name?: string;
systemColor?: string | 'systemBackgroundColor';
red?: string;
green?: string;
blue?: string;
alpha?: string;
colorSpace?: string;
customColorSpace?: string;
}>;
export type IBDevice = IBItem<{
id: string;
orientation: string | 'portrait';
appearance: string | 'light';
}>;
export type IBSplashScreenDocument = {
document: IBItem<
{
type: 'com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB' | string;
version: '3.0' | string;
toolsVersion: number;
targetRuntime: 'iOS.CocoaTouch' | string;
propertyAccessControl: 'none' | string;
useAutolayout: IBBoolean;
launchScreen: IBBoolean;
useTraitCollections: IBBoolean;
useSafeAreas: IBBoolean;
colorMatched: IBBoolean;
initialViewController: string;
},
{
device: IBDevice[];
dependencies: unknown[];
scenes: {
scene: IBScene[];
}[];
resources: {
image: IBResourceImage[];
namedColor?: IBItem<{ name: string }, { color: IBResourceNamedColor[] }>[];
}[];
}
>;
};
export function createConstraint(
[firstItem, firstAttribute]: [string, ConstraintAttribute],
[secondItem, secondAttribute]: [string, ConstraintAttribute],
constant?: number
): IBConstraint {
return {
$: {
firstItem,
firstAttribute,
secondItem,
secondAttribute,
constant,
// Prevent updating between runs
id: createConstraintId(firstItem, firstAttribute, secondItem, secondAttribute),
},
};
}
export function createConstraintId(...attributes: string[]) {
return crypto.createHash('sha1').update(attributes.join('-')).digest('hex');
}
const IMAGE_ID = 'EXPO-SplashScreen';
const CONTAINER_ID = 'EXPO-ContainerView';
export function removeImageFromSplashScreen(
xml: IBSplashScreenDocument,
{ imageName }: { imageName: string }
) {
const mainView = xml.document.scenes[0]?.scene[0]?.objects[0]?.viewController[0]?.view[0];
if (mainView != null) {
if (mainView.subviews[0] != null) {
removeExisting(mainView.subviews[0].imageView, IMAGE_ID);
}
// Remove Constraints
getAbsoluteConstraints(IMAGE_ID, CONTAINER_ID).forEach((constraint) => {
// <constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="2VS-Uz-0LU"/>
if (mainView.constraints[0] != null) {
removeExisting(mainView.constraints[0].constraint, constraint);
}
});
}
// Remove resource
if (xml.document.resources[0] != null) {
xml.document.resources[0].image = xml.document.resources[0].image ?? [];
const imageSection = xml.document.resources[0].image;
const existingImageIndex = imageSection.findIndex((image) => image.$.name === imageName);
if (existingImageIndex && existingImageIndex > -1) {
imageSection?.splice(existingImageIndex, 1);
}
}
return xml;
}
function getAbsoluteConstraints(childId: string, parentId: string, legacy: boolean = false) {
if (legacy) {
return [
createConstraint([childId, 'top'], [parentId, 'top']),
createConstraint([childId, 'leading'], [parentId, 'leading']),
createConstraint([childId, 'trailing'], [parentId, 'trailing']),
createConstraint([childId, 'bottom'], [parentId, 'bottom']),
];
}
return [
createConstraint([childId, 'centerX'], [parentId, 'centerX']),
createConstraint([childId, 'centerY'], [parentId, 'centerY']),
];
}
export function applyImageToSplashScreenXML(
xml: IBSplashScreenDocument,
{
imageName,
contentMode,
backgroundColor = '#ffffff',
enableFullScreenImage,
imageWidth = 100,
}: {
imageName: string;
contentMode: ImageContentMode;
backgroundColor?: string;
enableFullScreenImage: boolean;
imageWidth?: number;
}
): IBSplashScreenDocument {
const mainView = xml.document.scenes[0]?.scene[0]?.objects[0]?.viewController[0]?.view[0];
const rect = mainView?.rect[0];
const width = enableFullScreenImage ? 414 : imageWidth;
const height = enableFullScreenImage ? 736 : imageWidth;
const x = enableFullScreenImage || rect == null ? 0 : (rect.$.width - width) / 2;
const y = enableFullScreenImage || rect == null ? 0 : (rect.$.height - height) / 2;
const imageView: IBImageView = {
$: {
id: IMAGE_ID,
userLabel: imageName,
image: imageName,
contentMode,
clipsSubviews: true,
userInteractionEnabled: false,
translatesAutoresizingMaskIntoConstraints: false,
},
rect: [
{
$: {
key: 'frame',
x,
y,
width,
height,
},
},
],
};
if (mainView != null) {
// Add ImageView
if (mainView.subviews[0] != null) {
ensureUniquePush(mainView.subviews[0].imageView, imageView);
}
if (mainView.constraints[0] != null) {
mainView.constraints[0].constraint = [];
}
// Add Constraints
getAbsoluteConstraints(IMAGE_ID, CONTAINER_ID, enableFullScreenImage).forEach(
(constraint: IBConstraint) => {
if (mainView.constraints[0] != null) {
ensureUniquePush(mainView.constraints[0].constraint, constraint);
}
}
);
// Clear existing color
mainView.color = [];
// Add background color
const colorSection = mainView.color;
colorSection.push({
$: {
key: 'backgroundColor',
name: 'SplashScreenBackground',
},
});
}
if (xml.document.resources[0] != null) {
// Clear existing images
xml.document.resources[0].image = [];
// Add resource
const imageSection = xml.document.resources[0].image;
imageSection.push({
$: {
name: imageName,
width,
height,
},
});
// Clear existing named colors
xml.document.resources[0].namedColor = [];
const namedColorSection = xml.document.resources[0].namedColor;
// Add background named color reference
const color = parseColor(backgroundColor);
namedColorSection.push({
$: {
name: 'SplashScreenBackground',
},
color: [
{
$: {
alpha: '1.000',
blue: color.rgb.blue,
green: color.rgb.green,
red: color.rgb.red,
customColorSpace: 'sRGB',
colorSpace: 'custom',
},
},
],
});
}
return xml;
}
/**
* IB does not allow two items to have the same ID.
* This method will add an item by first removing any existing item with the same `$.id`.
*/
export function ensureUniquePush<TItem extends { $: { id: string } }>(array: TItem[], item: TItem) {
if (!array) return array;
removeExisting(array, item);
array.push(item);
return array;
}
export function removeExisting<TItem extends { $: { id: string } }>(
array: TItem[],
item: TItem | string
) {
const id = typeof item === 'string' ? item : item.$?.id;
const existingItem = array?.findIndex((existingItem) => existingItem.$.id === id);
if (existingItem > -1) {
array.splice(existingItem, 1);
}
return array;
}
// Attempt to copy Xcode formatting.
export function toString(xml: any): string {
const builder = new Builder({
// @ts-expect-error: untyped
preserveChildrenOrder: true,
xmldec: {
version: '1.0',
encoding: 'UTF-8',
},
renderOpts: {
pretty: true,
indent: ' ',
},
});
return builder.buildObject(xml);
}
/** Parse string contents into an object. */
export function toObjectAsync(contents: string) {
return new Parser().parseStringPromise(contents);
}
// Function taken from react-native-bootsplash
export const parseColor = (value: string): Color => {
const color = value.toUpperCase().replace(/[^0-9A-F]/g, '');
if (color.length !== 3 && color.length !== 6) {
console.error(`"${value}" value is not a valid hexadecimal color.`);
process.exit(1);
}
const hex =
color.length === 3
? '#' + color[0] + color[0] + color[1] + color[1] + color[2] + color[2]
: '#' + color;
const rgb: Color['rgb'] = {
red: (parseInt('' + hex[1] + hex[2], 16) / 255).toPrecision(15),
green: (parseInt('' + hex[3] + hex[4], 16) / 255).toPrecision(15),
blue: (parseInt('' + hex[5] + hex[6], 16) / 255).toPrecision(15),
};
return { hex, rgb };
};
type Color = {
hex: string;
rgb: {
red: string;
green: string;
blue: string;
};
};