From b54949e4fcccf4165da23d2acffd27f2a7686147 Mon Sep 17 00:00:00 2001 From: Kerri Shotts Date: Wed, 24 Aug 2016 17:49:20 -0500 Subject: [PATCH] CB-9762 Add launch storyboard support * No default splash images are provided as they would be overwritten anyway upon adding the platform. * Storyboard will open in Xcode 6, but is NOT supported on Xcode 6. Xcode 7 is the minimum required version for this feature. * Compatibility: Node 0.x versions don't understand Array.find(); used .reduce() instead with appropriate comments in the code. --- .../CDVLaunchScreen.storyboard | 42 +++ .../Images.xcassets/Contents.json | 6 + .../LaunchStoryboard.imageset/Contents.json | 168 ++++++++++ .../__TEMP__.xcodeproj/project.pbxproj | 4 + bin/templates/scripts/cordova/lib/prepare.js | 301 ++++++++++++++++++ 5 files changed, 521 insertions(+) create mode 100644 bin/templates/project/__PROJECT_NAME__/CDVLaunchScreen.storyboard create mode 100644 bin/templates/project/__PROJECT_NAME__/Images.xcassets/Contents.json create mode 100644 bin/templates/project/__PROJECT_NAME__/Images.xcassets/LaunchStoryboard.imageset/Contents.json diff --git a/bin/templates/project/__PROJECT_NAME__/CDVLaunchScreen.storyboard b/bin/templates/project/__PROJECT_NAME__/CDVLaunchScreen.storyboard new file mode 100644 index 000000000..0cb1e6606 --- /dev/null +++ b/bin/templates/project/__PROJECT_NAME__/CDVLaunchScreen.storyboard @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bin/templates/project/__PROJECT_NAME__/Images.xcassets/Contents.json b/bin/templates/project/__PROJECT_NAME__/Images.xcassets/Contents.json new file mode 100644 index 000000000..da4a164c9 --- /dev/null +++ b/bin/templates/project/__PROJECT_NAME__/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/bin/templates/project/__PROJECT_NAME__/Images.xcassets/LaunchStoryboard.imageset/Contents.json b/bin/templates/project/__PROJECT_NAME__/Images.xcassets/LaunchStoryboard.imageset/Contents.json new file mode 100644 index 000000000..71f82acb5 --- /dev/null +++ b/bin/templates/project/__PROJECT_NAME__/Images.xcassets/LaunchStoryboard.imageset/Contents.json @@ -0,0 +1,168 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "idiom" : "universal", + "scale" : "1x", + "height-class" : "compact" + }, + { + "idiom" : "universal", + "scale" : "2x", + "height-class" : "compact" + }, + { + "idiom" : "universal", + "height-class" : "compact", + "scale" : "3x" + }, + { + "idiom" : "universal", + "scale" : "1x", + "width-class" : "compact" + }, + { + "idiom" : "universal", + "width-class" : "compact", + "scale" : "2x" + }, + { + "idiom" : "universal", + "width-class" : "compact", + "scale" : "3x" + }, + { + "idiom" : "universal", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "1x" + }, + { + "idiom" : "universal", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "2x" + }, + { + "idiom" : "universal", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "scale" : "1x" + }, + { + "idiom" : "iphone", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "scale" : "1x", + "height-class" : "compact" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "height-class" : "compact" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "height-class" : "compact" + }, + { + "idiom" : "iphone", + "scale" : "1x", + "width-class" : "compact" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "width-class" : "compact" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "width-class" : "compact" + }, + { + "idiom" : "iphone", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "1x" + }, + { + "idiom" : "iphone", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "height-class" : "compact" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "height-class" : "compact" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "width-class" : "compact" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "width-class" : "compact" + }, + { + "idiom" : "ipad", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "width-class" : "compact", + "height-class" : "compact", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/bin/templates/project/__TEMP__.xcodeproj/project.pbxproj b/bin/templates/project/__TEMP__.xcodeproj/project.pbxproj index c73f55226..e9adaa9b6 100755 --- a/bin/templates/project/__TEMP__.xcodeproj/project.pbxproj +++ b/bin/templates/project/__TEMP__.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 3047A5121AB8059700498E2A /* build-debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 3047A50F1AB8059700498E2A /* build-debug.xcconfig */; }; 3047A5131AB8059700498E2A /* build-release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 3047A5101AB8059700498E2A /* build-release.xcconfig */; }; 3047A5141AB8059700498E2A /* build.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 3047A5111AB8059700498E2A /* build.xcconfig */; }; + 6AFF5BF91D6E424B00AB3073 /* CDVLaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6AFF5BF81D6E424B00AB3073 /* CDVLaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,6 +51,7 @@ 3047A5101AB8059700498E2A /* build-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = "build-release.xcconfig"; path = cordova/build-release.xcconfig; sourceTree = SOURCE_ROOT; }; 3047A5111AB8059700498E2A /* build.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = build.xcconfig; path = cordova/build.xcconfig; sourceTree = SOURCE_ROOT; }; 32CA4F630368D1EE00C91783 /* __PROJECT_NAME__-Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "__PROJECT_NAME__-Prefix.pch"; sourceTree = ""; }; + 6AFF5BF81D6E424B00AB3073 /* CDVLaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = CDVLaunchScreen.storyboard; path = "__PROJECT_NAME__/CDVLaunchScreen.storyboard"; sourceTree = SOURCE_ROOT; }; 8D1107310486CEB800E47090 /* __PROJECT_NAME__-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "__PROJECT_NAME__-Info.plist"; path = "__PROJECT_NAME__/__PROJECT_NAME__-Info.plist"; plistStructureDefinitionIdentifier = "com.apple.xcode.plist.structure-definition.iphone.info-plist"; sourceTree = SOURCE_ROOT; }; EB87FDF31871DA8E0020F90C /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../../www; sourceTree = ""; }; EB87FDF41871DAF40020F90C /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = config.xml; path = ../../config.xml; sourceTree = ""; }; @@ -124,6 +126,7 @@ 0207DA571B56EA530066E2B4 /* Images.xcassets */, 3047A50E1AB8057F00498E2A /* config */, 8D1107310486CEB800E47090 /* __PROJECT_NAME__-Info.plist */, + 6AFF5BF81D6E424B00AB3073 /* CDVLaunchScreen.storyboard */, ); name = Resources; path = "__PROJECT_NAME__/Resources"; @@ -237,6 +240,7 @@ files = ( 302D95F214D2391D003F00A1 /* MainViewController.xib in Resources */, 0207DA581B56EA530066E2B4 /* Images.xcassets in Resources */, + 6AFF5BF91D6E424B00AB3073 /* CDVLaunchScreen.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/bin/templates/scripts/cordova/lib/prepare.js b/bin/templates/scripts/cordova/lib/prepare.js index 10d1c410b..873297a1c 100644 --- a/bin/templates/scripts/cordova/lib/prepare.js +++ b/bin/templates/scripts/cordova/lib/prepare.js @@ -53,6 +53,7 @@ module.exports.prepare = function (cordovaProject, options) { .then(function () { updateIcons(cordovaProject, self.locations); updateSplashScreens(cordovaProject, self.locations); + updateLaunchStoryboardImages(cordovaProject, self.locations); }) .then(function () { events.emit('verbose', 'Prepared iOS project successfully'); @@ -78,6 +79,7 @@ module.exports.clean = function (options) { cleanWww(projectRoot, self.locations); cleanIcons(projectRoot, projectConfig, self.locations); cleanSplashScreens(projectRoot, projectConfig, self.locations); + cleanLaunchStoryboardImages(projectRoot, projectConfig, self.locations); }); }; @@ -205,6 +207,7 @@ function updateProject(platformConfig, locations) { } handleOrientationSettings(platformConfig, infoPlist); + updateProjectPlistForLaunchStoryboard(platformConfig, infoPlist); var info_contents = plist.build(infoPlist); info_contents = info_contents.replace(/[\s\r\n]*<\/string>/g,''); @@ -437,6 +440,304 @@ function cleanSplashScreens(projectRoot, projectConfig, locations) { } } +/** + * Returns an array of images for each possible idiom, scale, and size class. The images themselves are + * located in the platform's splash images by their pattern (@scale~idiom~sizesize). All possible + * combinations are returned, but not all will have a `filename` property. If the latter isn't present, + * the device won't attempt to load an image matching the same traits. If the filename is present, + * the device will try to load the image if it corresponds to the traits. + * + * The resulting return looks like this: + * + * [ + * { + * idiom: 'universal|ipad|iphone', + * scale: '1x|2x|3x', + * width: 'any|com', + * height: 'any|com', + * filename: undefined|'Default@scale~idiom~widthheight.png', + * src: undefined|'path/to/original/matched/image/from/splash/screens.png', + * target: undefined|'path/to/asset/library/Default@scale~idiom~widthheight.png' + * }, ... + * ] + * + * @param {Array} splashScreens splash screens as defined in config.xml for this platform + * @param {string} launchStoryboardImagesDir project-root/Images.xcassets/LaunchStoryboard.imageset/ + * @return {Array} + */ +function mapLaunchStoryboardContents(splashScreens, launchStoryboardImagesDir) { + var platformLaunchStoryboardImages = []; + var idioms = ['universal', 'ipad', 'iphone']; + var scalesForIdiom = { + universal: ['1x', '2x', '3x'], + ipad: ['1x', '2x'], + iphone: ['1x', '2x', '3x'] + }; + var sizes = ['com', 'any']; + + idioms.forEach(function (idiom) { + scalesForIdiom[idiom].forEach(function (scale) { + sizes.forEach(function(width) { + sizes.forEach(function(height) { + var item = { + idiom: idiom, + scale: scale, + width: width, + height: height + }; + + /* examples of the search pattern: + * scale ~ idiom ~ width height + * @2x ~ universal ~ any any + * @3x ~ iphone ~ com any + * @2x ~ ipad ~ com any + */ + var searchPattern = '@' + scale + '~' + idiom + '~' + width + height; + + /* because old node versions don't have Array.find, the below is + * functionally equivalent to this: + * var launchStoryboardImage = splashScreens.find(function(item) { + * return item.src.indexOf(searchPattern) >= 0; + * }); + */ + var launchStoryboardImage = splashScreens.reduce(function (p, c) { + return (c.src.indexOf(searchPattern) >= 0) ? c : p; + }, undefined); + + if (launchStoryboardImage) { + item.filename = 'Default' + searchPattern + '.png'; + item.src = launchStoryboardImage.src; + item.target = path.join(launchStoryboardImagesDir, item.filename); + } + + platformLaunchStoryboardImages.push(item); + }); + }); + }); + }); + return platformLaunchStoryboardImages; +} + +/** + * Returns a dictionary representing the source and destination paths for the launch storyboard images + * that need to be copied. + * + * The resulting return looks like this: + * + * { + * 'target-path': 'source-path', + * ... + * } + * + * @param {Array} splashScreens splash screens as defined in config.xml for this platform + * @param {string} launchStoryboardImagesDir project-root/Images.xcassets/LaunchStoryboard.imageset/ + * @return {Object} + */ +function mapLaunchStoryboardResources(splashScreens, launchStoryboardImagesDir) { + var platformLaunchStoryboardImages = mapLaunchStoryboardContents(splashScreens, launchStoryboardImagesDir); + var pathMap = {}; + platformLaunchStoryboardImages.forEach(function (item) { + if (item.target) { + pathMap[item.target] = item.src; + } + }); + return pathMap; +} + +/** + * Builds the object that represents the contents.json file for the LaunchStoryboard image set. + * + * The resulting return looks like this: + * + * { + * images: [ + * { + * idiom: 'universal|ipad|iphone', + * scale: '1x|2x|3x', + * width-class: undefined|'compact', + * height-class: undefined|'compact' + * }, ... + * ], + * info: { + * author: 'Xcode', + * version: 1 + * } + * } + * + * A bit of minor logic is used to map from the array of images returned from mapLaunchStoryboardContents + * to the format requried by Xcode. + * + * @param {Array} splashScreens splash screens as defined in config.xml for this platform + * @param {string} launchStoryboardImagesDir project-root/Images.xcassets/LaunchStoryboard.imageset/ + * @return {Object} + */ +function getLaunchStoryboardContentsJSON(splashScreens, launchStoryboardImagesDir) { + var IMAGESET_COMPACT_SIZE_CLASS = 'compact'; + var CDV_ANY_SIZE_CLASS = 'any'; + + var platformLaunchStoryboardImages = mapLaunchStoryboardContents(splashScreens, launchStoryboardImagesDir); + var contentsJSON = { + images: [], + info: { + author: 'Xcode', + version: 1 + } + }; + contentsJSON.images = platformLaunchStoryboardImages.map(function(item) { + var newItem = { + idiom: item.idiom, + scale: item.scale + }; + + // Xcode doesn't want any size class property if the class is "any" + // If our size class is "com", Xcode wants "compact". + if (item.width !== CDV_ANY_SIZE_CLASS) { + newItem['width-class'] = IMAGESET_COMPACT_SIZE_CLASS; + } + if (item.height !== CDV_ANY_SIZE_CLASS) { + newItem['height-class'] = IMAGESET_COMPACT_SIZE_CLASS; + } + + // Xcode doesn't want a filename property if there's no image for these traits + if (item.filename) { + newItem.filename = item.filename; + } + return newItem; + }); + return contentsJSON; +} + +/** + * Updates the project's plist based upon our launch storyboard images. If there are no images, then we should + * fall back to the regular launch images that might be supplied (that is, our app will be scaled on an iPad Pro), + * and if there are some images, we need to alter the UILaunchStoryboardName property to point to + * CDVLaunchScreen. + * + * There's some logic here to avoid overwriting changes the user might have made to their plist if they are using + * their own launch storyboard. + */ +function updateProjectPlistForLaunchStoryboard(platformConfig, infoPlist) { + var UI_LAUNCH_STORYBOARD_NAME = 'UILaunchStoryboardName'; + var CDV_LAUNCH_STORYBOARD_NAME = 'CDVLaunchScreen'; + + var splashScreens = platformConfig.getSplashScreens('ios'); + var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, ''); // note: we don't need a file path here; we're just counting + var currentLaunchStoryboard = infoPlist[UI_LAUNCH_STORYBOARD_NAME]; + + events.emit('verbose', 'Current launch storyboard ' + currentLaunchStoryboard); + + + /* do we have any launch images do we have for our launch storyboard? + * Again, for old Node versions, the below code is equivalent to this: + * var hasLaunchStoryboardImages = !!contentsJSON.images.find(function (item) { + * return item.filename !== undefined; + * }); + */ + var hasLaunchStoryboardImages = !!contentsJSON.images.reduce(function (p, c) { + return (c.filename !== undefined) ? c : p; + }, undefined); + + if (hasLaunchStoryboardImages && !currentLaunchStoryboard) { + // only change the launch storyboard if we have images to use AND the current value is blank + // if it's not blank, we've either done this before, or the user has their own launch storyboard + events.emit('verbose', 'Changing project to use our launch storyboard'); + infoPlist[UI_LAUNCH_STORYBOARD_NAME] = CDV_LAUNCH_STORYBOARD_NAME; + return; + } + + if (!hasLaunchStoryboardImages && currentLaunchStoryboard === CDV_LAUNCH_STORYBOARD_NAME) { + // only revert to using the launch images if we have don't have any images for the launch storyboard + // but only clear it if current launch storyboard is our storyboard; the user might be using their + // own storyboard instead. + events.emit('verbose', 'Changing project to use launch images'); + infoPlist[UI_LAUNCH_STORYBOARD_NAME] = undefined; + return; + } + events.emit('verbose', 'Not changing launch storyboard setting.'); +} + +/** + * Returns the directory for the Launch Storyboard image set, if image sets are being used. If they aren't + * being used, returns null. + * + * @param {string} projectRoot The project's root directory + * @param {string} platformProjDir The platform's project directory + */ +function getLaunchStoryboardImagesDir(projectRoot, platformProjDir) { + var launchStoryboardImagesDir; + var xcassetsExists = folderExists(path.join(projectRoot, platformProjDir, 'Images.xcassets/')); + + if (xcassetsExists) { + launchStoryboardImagesDir = path.join(platformProjDir, 'Images.xcassets/LaunchStoryboard.imageset/'); + } else { + // if we don't have a asset library for images, we can't do the storyboard. + launchStoryboardImagesDir = null; + } + + return launchStoryboardImagesDir; +} + +/** + * Update the images for the Launch Storyboard and updates the image set's contents.json file appropriately. + * + * @param {Object} cordovaProject The cordova project + * @param {Object} locations A dictionary containing useful location paths + */ +function updateLaunchStoryboardImages(cordovaProject, locations) { + var splashScreens = cordovaProject.projectConfig.getSplashScreens('ios'); + var platformProjDir = path.relative(cordovaProject.root, locations.xcodeCordovaProj); + var launchStoryboardImagesDir = getLaunchStoryboardImagesDir(cordovaProject.root, platformProjDir); + + if (launchStoryboardImagesDir) { + var resourceMap = mapLaunchStoryboardResources(splashScreens, launchStoryboardImagesDir); + var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, launchStoryboardImagesDir); + + events.emit('verbose', 'Updating launch storyboard images at ' + launchStoryboardImagesDir); + FileUpdater.updatePaths( + resourceMap, { rootDir: cordovaProject.root }, logFileOp); + + events.emit('verbose', 'Updating Storyboard image set contents.json'); + fs.writeFileSync(path.join(launchStoryboardImagesDir, 'contents.json'), + JSON.stringify(contentsJSON, null, 2)); + } +} + +/** + * Removes the images from the launch storyboard's image set and updates the image set's contents.json + * file appropriately. + * + * @param {string} projectRoot Path to the project root + * @param {Object} projectConfig The project's config.xml + * @param {Object} locations A dictionary containing useful location paths + */ +function cleanLaunchStoryboardImages(projectRoot, projectConfig, locations) { + var splashScreens = projectConfig.getSplashScreens('ios'); + var platformProjDir = path.relative(projectRoot, locations.xcodeCordovaProj); + var launchStoryboardImagesDir = getLaunchStoryboardImagesDir(projectRoot, platformProjDir); + if (launchStoryboardImagesDir) { + var resourceMap = mapLaunchStoryboardResources(splashScreens, launchStoryboardImagesDir); + var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, launchStoryboardImagesDir); + + Object.keys(resourceMap).forEach(function (targetPath) { + resourceMap[targetPath] = null; + }); + events.emit('verbose', 'Cleaning storyboard image set at ' + launchStoryboardImagesDir); + + // Source paths are removed from the map, so updatePaths() will delete the target files. + FileUpdater.updatePaths( + resourceMap, { rootDir: projectRoot, all: true }, logFileOp); + + // delete filename from contents.json + contentsJSON.images.forEach(function(image) { + image.filename = undefined; + }); + + events.emit('verbose', 'Updating Storyboard image set contents.json'); + fs.writeFileSync(path.join(launchStoryboardImagesDir, 'contents.json'), + JSON.stringify(contentsJSON, null, 2)); + } +} + /** * Queries ConfigParser object for the orientation value. Warns if * global preference value is not supported by platform.