How to create a Today Widget for your iOS App in Swift

This tutorial explains how to create a new Today Widget for iOS in Swift 3 using App Extensions in Xcode.  It covers how to import pods, set up entitlements for inter-app communication via user defaults and custom url schemes.  It also explains how to remove the existing storyboard and implement the today widget view programmatically instead using Auto-Layout.

Create the Widget

First, Select your project and then create a new target via File, New, Target…

From the iOS tab, choose the “Today Extension” template. Press Next.

Fill in the Product Name and select the appropriate “Embed in Application” target.

Remove Storyboards

If you don’t want to use storyboards then delete the “MainInterface.storyboard” file.  From Info.plist, navigate to the NSExtension dictionary and delete the NSExtensionMainStoryboard key.  Add a new key to the NSExtension dictionary titled “NSExtensionPrincipalClass” and for the value choose your main “ViewController.swift” that conforms to the NCWidgetProviding protocol.

Also add the @objc(ViewController) tag to the top of your View Controller class to avoid the error “Terminating app due to uncaught exception ‘NSInvalidArgumentException’, reason: ‘*** setObjectForKey: object cannot be nil”.

Import Pods

If you would like to use cocoa pods with your widget, then add the new widget target to the Podfile with a new target.  The below example will add the SnapKit pod to the “TodayExtension” widget.

target 'TodayExtension' do
    platform :ios, '9.0'
    use_frameworks!
    pod 'SnapKit', '~> 3.2.0'
end

Create the View

You can use auto layout to create your today widget view.  The width of the widget is always fixed. By default the today widget is in a compact size and has a fixed height of 110pts.  However, the user can press “Show More” on the widget to expand it larger and “Show Less” to compact it. When in the “Show More” or .expanded state the widget can have a variable height up to the size of the screen.

To know when the user has changed the display mode implement func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize)
You can determine the active display mode and retrieve the max width and heights available via the below.  If you are in expanded NCWidgetDisplayMode then use .expanded instead of .compact

if self.extensionContext!.widgetActiveDisplayMode == .compact {
    let todayWidth = self.extensionContext!.widgetMaximumSize(for: .compact).width
    let todayHeight = self.extensionContext!.widgetMaximumSize(for: .compact).height
}

Entitlements

If you would like to share data between the today widget and your container/host application you will need to add the “App Groups” entitlement.  From your project, go to your widget’s Target and select the Capabilities tab.  Turn on App Groups and add a new App Group with a unique name such as “group.com.domain.app”.  Go to your container/host application’s Capabilities  and turn on App Groups also, select same app group you previously created.

You can now share data between the two apps using UserDefaults suite name.  For example: let sharedDefaults = UserDefaults(suiteName: "group.com.domain.app")!

 

Custom URL

If you would like to communicate a press or tap on your Today Widget to open your container/host application you will need to set up a custom url scheme.  Follow the first step on how to register a custom URL scheme here How to open an iOS app with custom URL.

From your today widget you can now use the below sample code to open a custom url:

    self.extensionContext?.open(url, completionHandler: { (Bool) in
        //Handle callback
    })

From your container/host application, implement the following function in AppDelegate.swift to handle the custom url:

func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
    //do something
    return true
}

 

Sources:

https://developer.apple.com/library/content/documentation/IDEs/Conceptual/AppDistributionGuide/AddingCapabilities/AddingCapabilities.html

https://developer.apple.com/library/content/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html#//apple_ref/doc/uid/TP40014214-CH21-SW1

https://developer.apple.com/library/content/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/Inter-AppCommunication/Inter-AppCommunication.html#//apple_ref/doc/uid/TP40007072-CH6-SW10

 

Deep Linking to the iOS App Store

Deep link to an app in the app store:

https://itunes.apple.com/app/id<APP_ID>

 

Deep link to an apps “Reviews” tab in the app store:

https://itunes.apple.com/WebObjects/MZStore.woa/wa/viewContentsUserReviews?id=<APP_ID>&pageNumber=0&sortOrdering=2&type=Purple+Software&mt=8

 

Deep link to an apps “Write a Review” page in the app store:

https://itunes.apple.com/app/id<APP_ID>?action=write-review

Source: https://developer.apple.com/reference/storekit/skstorereviewcontroller/2851536-requestreview
 

Deep link to all apps by a developer in the app store:

itms-apps://itunes.apple.com/developer/<DEVELOPER_NAME>/id<DEVELOPER_ID>

 

Remember to replace <APP_ID> with the actual app’s Apple ID. Note that if you are deep linking from the iPhone or iPad you can replace the https:// with itms-apps:// to avoid a redirects.

Maximum String Length for Common Database Fields

When designing a database schema you often have to make decisions on a fields length. Rather then just guess at a number, use the maximum possible value that could conceivably be used for the field. Below is a chart of common database fields such as email address, city, zip code and country name and the maximum possible length value that can be used.

Field Name Character Length Example
email address 254 see RFC5321
city 189 Longest City Name is Bangkok in Thai: Krung Thep Mahanakhon Amon Rattanakosin Mahinthara Ayuthaya Mahadilok Phop Noppharat Ratchathani Burirom Udomratchaniwet Mahasathan Amon Piman Awatan Sathit Sakkathattiya Witsanukam Prasit
zip code 18 Country with the longest zip code format is Chile with NNNNNNN, NNN-NNNN
country 90 Longest country name is Libya’s Arabic name prior to 2013: al-Jam-h-riyyah al-‘Arabiyyah al-L?biyyah ash-Sha‘biyyah al-Ishtir-kiyyah al-‘U-má

Create Apple Search Ads for Multiple Countries (Storefronts)

When Apple Search Ads launched in October 2016 it only displayed ads in the United States.  However, it expanded to multiple English speaking countries on April 25, 2017 to include the United Kingdom, Australia, and New Zealand.  Each country is represented by a different storefront and to get your ad to display in each storefront you need to create a separate campaign for each country.

 

To get your ad to display in each additional country you will need to create a new campaign and fill in as a minimum the App Name, Storefront, Campaign Name, and Budget.  You will also need to set an initial Ad Group Name, and Default Max CPT Bid.  You can fill out any of the other optional information and then press Start Campaign.  I recommend keeping the Campaign Name short but descriptive and then appending the country name to the end.

 

All bids are made in the currency that you set when you created your Apple Search Ads account.  So you should adjust you CPT and CPA for each country (storefront) due to exchange rates and taxes.  If different countries have different conversion rates then you’ll want to take that into considerations as well.

 

If you have multiple Ad Groups you can copy them over to the new campaign.  Go to your original campaign and select the ad group(s) by pressing the check boxes.  Press the Actions menu and select Duplicate.  From here you can choose to copy over the Settings and keywords or just the settings.  Select a destination campaign and a start/end date (optional) and press Duplicate.

 

To track keyword level attribution and ROI across multiple campaigns check out the Kitemetrics service.

How to set the name of the sender/from/source in Amazon SES

If you are using Amazon Simple Email Service via AWS you can make your emails appear more professional by setting the from name to either a person or company name instead of just the from email address.

To accomplish this via the command line, SDK, or API simply replace the source email address “from@domain.com” with “Company Name <from@domain.com>”.

iTunes Search API Country Codes

The iTunes Search API takes a country code. There are 28 languages that the iOS App Store can be localized into. Below are the ISO 3166-1 alpha-2 country codes for 25 of the main countries that can be used in the country parameter in the iTunes Search API.

CN
DK
NL
AT
CA
US
GB
FI
FR
DE
GR
ID
IT
JP
KR
MY
BR
PT
RU
MX
ES
SE
TH
TR
VN

How to get expansion APK files with downloader library and market licensing modules to work in Android Studio

Google gives an overview of what expansion APK files are along with some sample code.  However, getting started can be tricky.  Here is an easier to understand set of instructions for importing the necessary libraries as modules in Android Studio.

The following instructions were tested with Android Studio version 2.2.3 on a mac.  Note that on a mac the android sdk folder is at /Library/Android/sdk/.

Download libraries

  • Android Studio…Preferences…Launch Standalone SDK Manager
  • Under Extras, select “Google Play APK Expansion library” and “Google Play Licensing Library”.  Install packages… Accept License and Install.

Import market_licensing library

  • File…New…Import Module…
  • Browse to <ANDROID_SDK>/extras/google/market_licensing/library/, Press OK
  • Rename Module name from library to market_licensing, Press Finish

Import downloader_library

  • Open the <ANDROID_SDK>/extras/google/market_apk_expansion/downloader_library/project.properties file and delete the last line that reads “android.library.reference.1=../market_licensing.”
  • File…New…Import Module…
  • Browse to <ANDROID_SDK>/extras/google/market_apk_expansion/downloader_library/, Press OK and Finish

Source: http://stackoverflow.com/a/35663344/1431520

Import zip library

This step is optional.  If your expansion apk files are in a zip package then this library can be helpful.

  • File…New…Import Module…
  • Browse to <ANDROID_SDK>/extras/google/market_apk_expansion/zip_file/, Press OK and Finish

Add user permissions

Add the following permissions to the AndroidManifest.xml. Note that the documentation only has the WRITE_EXTERNAL_STORAGE permission, but I couldn’t get it to work on my Galaxy S5 test device without the READ_EXTERNAL_STORAGE permission as well.

    <!-- Required to access Google Play Licensing -->
    <uses-permission android:name="com.android.vending.CHECK_LICENSE" />
    <!-- Required to download files from Google Play -->
    <uses-permission android:name="android.permission.INTERNET" />
    <!-- Required to keep CPU alive while downloading files (NOT to keep screen awake) -->
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <!-- Required to poll the state of the network connection and respond to changes -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <!-- Required to check whether Wi-Fi is enabled -->
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
    <!-- Required to read and write the expansion files on shared storage. -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

Also for API 23+ you need to request read/write permissions from the user during application runtime.  You will need to request Manifest.permission.READ_EXTERNAL_STORAGE and Manifest.permission.WRITE_EXTERNAL_STORAGE before downloading or reading the expansion files.

If you don’t have the proper storage permissions you may get a java.io.FileNotFoundException and an error trying to read your .obb file that states “open failed: EACCES (Permission denied)“.

Source: https://developer.android.com/training/permissions/requesting.html

Set up dependencies 

If you build now you may get the following error: “package com.google.android.vending.licensing does not exist”.  Resolve it with the following steps.

  • Right click the downloader_library module from your project view
  • Select “Open Module Settings”
  • Select “Dependencies” tab
  • Press plus sign + then select “3 Module Dependency”
  • Select “market_licensing” and press OK.

Incorporate downloader_sample code into your project

“Most of the time, Google Play downloads and saves your expansion files at the same time it downloads the APK to the device. However, in some cases Google Play cannot download the expansion files or the user might have deleted previously downloaded expansion files. To handle these situations, your app must be able to download the files itself when the main activity starts, using a URL provided by Google Play.”

The sample code to download the expansion APK files manually is at <ANDROID_SDK>/extras/google/market_apk_expansion/download_sample

Copy the source code files, rename them and modify them to meet your needs.  This includes SampleAlarmReceiver.java, SampleDownloaderActivity.java, and SampleDownloaderService.java.  Also copy layout file main.xml and merge the values file strings.xml with your own.

Modify LicenseChecker.java to make Service Intent explicit

If you build now you may get the following error: “java.lang.IllegalArgumentException: Service Intent must be explicit: Intent { act=com.android.vending.licensing.ILicensingService }“.

The root cause of the error is at line 150 of LicenseChecker.java in the market_licensing module.  You can press on the line number in the stack trace “at com.google.android.vending.licensing.LicenseChecker.checkAccess(LicenseChecker.java:150)” or navigate to the file.

add .setPackage("com.android.vending") to the end of the string parameter passed to the intent like so:

new String(
-    Base64.decode("Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U="))),
+    Base64.decode("Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U=")))
+    .setPackage("com.android.vending"), // this fixes 'IllegalArgumentException: Service Intent must be explicit'
     this, // ServiceConnection.

Change the minSdkVersion from 3 to 4 in the market_licensing/manifests/AndroidManifests.xml file

source: http://stackoverflow.com/a/29079160/1431520

Modify DownloaderService to fix WifiManagerLeak error

When you go to generate a signed APK you will get the error “Error:(575) Error: The WIFI_SERVICE must be looked up on the Application context or memory will leak on devices < Android N. Try changing  to .getApplicationContext()  [WifiManagerLeak]“.

The problem is in line 575 of the DownloaderService.java file within the downloader library.  Modify it as follows:

- mWifiManager = (WifiManager) getSystemService(Context.WIFI_SERVICE);
+ mWifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);

source: http://stackoverflow.com/a/42639000/1431520

Implementing the downloader service, alarm receiver and starting the download

Follow along with Google’s documentation at https://developer.android.com/google/play/expansion-files.html to implement the downloader service and alarm receiver.  Scroll down to the section titled “Implementing the downloader service“.

 

iOS views and their corresponding android views

If you are used to programming in iOS and need to write an android app or vice versa here is a list of iOS views and their equivalent views on android.  You can also use this list to infer the corresponding View Controller and Activity.

iOS android
UITableView ListView
UICollectionView GridView

How to programmatically play audio even when iOS device is muted

If you want to play the sound from a video or audio clip even when the iPhone or iPad device is muted you can use AVAudioSession to accomplish the task.  This will make your app work similar to YouTube.

You will want to set the AVAudioSession category to AVAudioSessionCategoryPlayback.  The session is a singleton so you only need to set it once, either shortly after app launch or prior to your audio or video playing.

Below is some sample Swift 3 code:

//Set the audio session to playback to ignore mute switch on device
do {
        try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
    } catch {
        //Didn't work
}

Your code to play the audio or video will remain unchanged. Below is an example using AVPlayer and AVPlayerViewController() in Swift 3. Don’t forget to keep a reference to the AVPlayer while the video is playing.

self.player = AVPlayer(url: videoURL)
if self.player != nil {
    let playerViewController = AVPlayerViewController()
    playerViewController.player = self.player
    presentingViewController.present(playerViewController, animated: true) {
    playerViewController.player!.play()
    }
}

Apple Documentation:
AVAudioSession
AVAudioSessionCategoryPlayback
AVPlayer
AVPlayerViewController

 

Tested with:

Xcode 8.2.1

iOS 10.2.1

Translate »