Skip to content

Commit

Permalink
fix(react-native-dynamic-app-icon): add exact ipad sizes 152/167
Browse files Browse the repository at this point in the history
…for exported icons (#233)

* fix(react-native-dynamic-app-icon): add exact ipad sizes `152x152`/`167x167` for exported icons

* fix(react-native-dynamic-app-icon): add ipad icons to pbxproj

* refactor(react-native-dynamic-app-icon): replace `scales` and `size` with `IconDimension` using additional dimensions for specific targets

* fix(react-native-dynamic-app-icon): add `width` and `height` to keep similar file name but override exact dimensions

* chore(react-native-dynamic-app-icon): enable tablet exports for icons

* fix(react-native-dynamic-app-icon): use proper plist names (without scales)
  • Loading branch information
byCedric authored May 7, 2024
1 parent fa4397d commit bc6856f
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 109 deletions.
3 changes: 3 additions & 0 deletions apps/react-native-dynamic-app-icon/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"name": "app-icon",
"icon": "./assets/icons/winter.png",
"platforms": ["ios"],
"ios": {
"supportsTablet": true
},
"plugins": [
[
"@config-plugins/react-native-dynamic-app-icon",
Expand Down
300 changes: 191 additions & 109 deletions packages/react-native-dynamic-app-icon/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ExpoConfig } from "@expo/config";
import { generateImageAsync } from "@expo/image-utils";
import {
ConfigPlugin,
ExportedConfigWithProps,
type ConfigPlugin,
IOSConfig,
withDangerousMod,
withInfoPlist,
Expand All @@ -12,55 +12,64 @@ import path from "path";
// @ts-ignore
import pbxFile from "xcode/lib/pbxFile";

const folderName = "DynamicAppIcons";
const size = 60;
const scales = [2, 3];
/** The default icon folder name to export to */
const ICON_FOLDER_NAME = "DynamicAppIcons";

/**
* The default icon dimensions to export.
*
* @see https://developer.apple.com/design/human-interface-guidelines/app-icons#iOS-iPadOS-app-icon-sizes
*/
const ICON_DIMENSIONS: IconDimensions[] = [
// iPhone, iPad, MacOS, ...
{ scale: 2, size: 60 },
{ scale: 3, size: 60 },
// iPad only
{ scale: 2, size: 60, width: 152, height: 152, target: "ipad" },
{ scale: 3, size: 60, width: 167, height: 167, target: "ipad" },
];

type IconDimensions = {
/** The scale of the icon itself, affets file name and width/height when omitted. */
scale: number;
/** Both width and height of the icon, affects file name only. */
size: number;
/** The width, in pixels, of the icon. Generated from `size` + `scale` when omitted */
width?: number;
/** The height, in pixels, of the icon. Generated from `size` + `scale` when omitted */
height?: number;
/** Special target of the icon dimension, if any */
target?: null | "ipad";
};

type IconSet = Record<string, { image: string; prerendered?: boolean }>;
type IconSet = Record<string, IconSetProps>;
type IconSetProps = { image: string; prerendered?: boolean };

type Props = {
icons: Record<string, { image: string; prerendered?: boolean }>;
dimensions: Required<IconDimensions>[];
};

function arrayToImages(images: string[]) {
return images.reduce(
(prev, curr, i) => ({ ...prev, [i]: { image: curr } }),
{},
);
}

const withDynamicIcon: ConfigPlugin<string[] | IconSet | void> = (
config,
props = {},
) => {
const _props = props || {};
const icons = resolveIcons(props);
const dimensions = resolveIconDimensions(config);

let prepped: Props["icons"] = {};
config = withIconXcodeProject(config, { icons, dimensions });
config = withIconInfoPlist(config, { icons, dimensions });
config = withIconImages(config, { icons, dimensions });

if (Array.isArray(_props)) {
prepped = arrayToImages(_props);
} else if (_props) {
prepped = _props;
}

config = withIconXcodeProject(config, { icons: prepped });
config = withIconInfoPlist(config, { icons: prepped });
config = withIconImages(config, { icons: prepped });
return config;
};

function getIconName(name: string, size: number, scale?: number) {
const fileName = `${name}-Icon-${size}x${size}`;

if (scale != null) {
return `${fileName}@${scale}x.png`;
}
return fileName;
}

const withIconXcodeProject: ConfigPlugin<Props> = (config, { icons }) => {
const withIconXcodeProject: ConfigPlugin<Props> = (
config,
{ icons, dimensions },
) => {
return withXcodeProject(config, async (config) => {
const groupPath = `${config.modRequest.projectName!}/${folderName}`;
const groupPath = `${config.modRequest.projectName!}/${ICON_FOLDER_NAME}`;
const group = IOSConfig.XcodeUtils.ensureGroupRecursively(
config.modResults,
groupPath,
Expand Down Expand Up @@ -109,9 +118,10 @@ const withIconXcodeProject: ConfigPlugin<Props> = (config, { icons }) => {

// Link new assets

await iterateIconsAsync({ icons }, async (key, icon, index) => {
for (const scale of scales) {
const iconFileName = getIconName(key, size, scale);
await iterateIconsAndDimensionsAsync(
{ icons, dimensions },
async (key, { dimension }) => {
const iconFileName = getIconFileName(key, dimension);

if (
!group?.children.some(
Expand All @@ -129,32 +139,50 @@ const withIconXcodeProject: ConfigPlugin<Props> = (config, { icons }) => {
} else {
console.log("Skipping duplicate: ", iconFileName);
}
}
});
},
);

return config;
});
};

const withIconInfoPlist: ConfigPlugin<Props> = (config, { icons }) => {
const withIconInfoPlist: ConfigPlugin<Props> = (
config,
{ icons, dimensions },
) => {
return withInfoPlist(config, async (config) => {
const altIcons: Record<
string,
{ CFBundleIconFiles: string[]; UIPrerenderedIcon: boolean }
> = {};

await iterateIconsAsync({ icons }, async (key, icon) => {
altIcons[key] = {
CFBundleIconFiles: [
// Must be a file path relative to the source root (not a icon set it seems).
// i.e. `Bacon-Icon-60x60` when the image is `ios/somn/appIcons/[email protected]`
getIconName(key, size),
],
UIPrerenderedIcon: !!icon.prerendered,
};
});
const altIconsByTarget: Partial<
Record<NonNullable<IconDimensions["target"]>, typeof altIcons>
> = {};

function applyToPlist(key: string) {
await iterateIconsAndDimensionsAsync(
{ icons, dimensions },
async (key, { icon, dimension }) => {
const plistItem = {
CFBundleIconFiles: [
// Must be a file path relative to the source root (not a icon set it seems).
// i.e. `Bacon-Icon-60x60` when the image is `ios/somn/appIcons/[email protected]`
getIconName(key, dimension),
],
UIPrerenderedIcon: !!icon.prerendered,
};

if (dimension.target) {
altIconsByTarget[dimension.target] =
altIconsByTarget[dimension.target] || {};
altIconsByTarget[dimension.target]![key] = plistItem;
} else {
altIcons[key] = plistItem;
}
},
);

function applyToPlist(key: string, icons: typeof altIcons) {
if (
typeof config.modResults[key] !== "object" ||
Array.isArray(config.modResults[key]) ||
Expand All @@ -164,89 +192,143 @@ const withIconInfoPlist: ConfigPlugin<Props> = (config, { icons }) => {
}

// @ts-expect-error
config.modResults[key].CFBundleAlternateIcons = altIcons;
config.modResults[key].CFBundleAlternateIcons = icons;

// @ts-expect-error
config.modResults[key].CFBundlePrimaryIcon = {
CFBundleIconFiles: ["AppIcon"],
};
}

// Apply for both tablet and phone support
applyToPlist("CFBundleIcons");
applyToPlist("CFBundleIcons~ipad");
// Apply for general phone support
applyToPlist("CFBundleIcons", altIcons);

// Apply for each target, like iPad
for (const [target, icons] of Object.entries(altIconsByTarget)) {
if (Object.keys(icons).length > 0) {
applyToPlist(`CFBundleIcons~${target}`, icons);
}
}

return config;
});
};

const withIconImages: ConfigPlugin<Props> = (config, props) => {
const withIconImages: ConfigPlugin<Props> = (config, { icons, dimensions }) => {
return withDangerousMod(config, [
"ios",
async (config) => {
await createIconsAsync(config, props);
const iosRoot = path.join(
config.modRequest.platformProjectRoot,
config.modRequest.projectName!,
);

// Delete all existing assets
await fs.promises
.rm(path.join(iosRoot, ICON_FOLDER_NAME), {
recursive: true,
force: true,
})
.catch(() => null);

// Ensure directory exists
await fs.promises.mkdir(path.join(iosRoot, ICON_FOLDER_NAME), {
recursive: true,
});

// Generate new assets
await iterateIconsAndDimensionsAsync(
{ icons, dimensions },
async (key, { icon, dimension }) => {
const iconFileName = getIconFileName(key, dimension);
const fileName = path.join(ICON_FOLDER_NAME, iconFileName);
const outputPath = path.join(iosRoot, fileName);

const { source } = await generateImageAsync(
{
projectRoot: config.modRequest.projectRoot,
cacheType: "react-native-dynamic-app-icon",
},
{
name: iconFileName,
src: icon.image,
removeTransparency: true,
backgroundColor: "#ffffff",
resizeMode: "cover",
width: dimension.width,
height: dimension.height,
},
);

await fs.promises.writeFile(outputPath, source);
},
);

return config;
},
]);
};

async function createIconsAsync(
config: ExportedConfigWithProps,
{ icons }: Props,
) {
const iosRoot = path.join(
config.modRequest.platformProjectRoot,
config.modRequest.projectName!,
);

// Delete all existing assets
await fs.promises
.rm(path.join(iosRoot, folderName), { recursive: true, force: true })
.catch(() => null);
// Ensure directory exists
await fs.promises.mkdir(path.join(iosRoot, folderName), { recursive: true });
// Generate new assets
await iterateIconsAsync({ icons }, async (key, icon) => {
for (const scale of scales) {
const iconFileName = getIconName(key, size, scale);
const fileName = path.join(folderName, iconFileName);
const outputPath = path.join(iosRoot, fileName);

const scaledSize = scale * size;
const { source } = await generateImageAsync(
{
projectRoot: config.modRequest.projectRoot,
cacheType: "react-native-dynamic-app-icon",
},
{
name: iconFileName,
src: icon.image,
removeTransparency: true,
backgroundColor: "#ffffff",
resizeMode: "cover",
width: scaledSize,
height: scaledSize,
},
);
/** Resolve and sanitize the icon set from config plugin props. */
function resolveIcons(props: string[] | IconSet | void): Props["icons"] {
let icons: Props["icons"] = {};

await fs.promises.writeFile(outputPath, source);
}
});
if (Array.isArray(props)) {
icons = props.reduce(
(prev, curr, i) => ({ ...prev, [i]: { image: curr } }),
{},
);
} else if (props) {
icons = props;
}

return icons;
}

async function iterateIconsAsync(
{ icons }: Props,
/** Resolve the required icon dimension/target based on the app config. */
function resolveIconDimensions(config: ExpoConfig): Required<IconDimensions>[] {
const targets: NonNullable<IconDimensions["target"]>[] = [];

if (config.ios?.supportsTablet) {
targets.push("ipad");
}

return ICON_DIMENSIONS.filter(
({ target }) => !target || targets.includes(target),
).map((dimension) => ({
...dimension,
target: dimension.target ?? null,
width: dimension.width ?? dimension.size * dimension.scale,
height: dimension.height ?? dimension.size * dimension.scale,
}));
}

/** Get the icon name, used to refer to the icon from within the plist */
function getIconName(name: string, dimension: Props["dimensions"][0]) {
return `${name}-Icon-${dimension.size}x${dimension.size}`;
}

/** Get the full icon file name, including scale and possible target, used to write each exported icon to */
function getIconFileName(name: string, dimension: Props["dimensions"][0]) {
const target = dimension.target ? `~${dimension.target}` : "";
return `${getIconName(name, dimension)}@${dimension.scale}x${target}.png`;
}

/** Iterate all combinations of icons and dimensions to export */
async function iterateIconsAndDimensionsAsync(
{ icons, dimensions }: Props,
callback: (
key: string,
icon: { image: string; prerendered?: boolean },
index: number,
iconKey: string,
iconAndDimension: {
icon: Props["icons"][string];
dimension: Props["dimensions"][0];
},
) => Promise<void>,
) {
const entries = Object.entries(icons);
for (let i = 0; i < entries.length; i++) {
const [key, val] = entries[i];

await callback(key, val, i);
for (const [iconKey, icon] of Object.entries(icons)) {
for (const dimension of dimensions) {
await callback(iconKey, { icon, dimension });
}
}
}

Expand Down

0 comments on commit bc6856f

Please sign in to comment.