-
Notifications
You must be signed in to change notification settings - Fork 104
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(react-native-dynamic-app-icon): add exact ipad sizes
152
/167
…
…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
Showing
2 changed files
with
194 additions
and
109 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
|
@@ -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, | ||
|
@@ -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( | ||
|
@@ -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]) || | ||
|
@@ -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 }); | ||
} | ||
} | ||
} | ||
|
||
|