Snippet of the Week: Rating Your App ⭐️


Everyone knows that a 5-star rating on the App Store is a great way to convince other users to download your app. And of course, we try to avoid 1-star reviews like the plague. However, how do you convince your user to leave a review?

Well, we’re not here to tell you how to ask for reviews. Every app is different, so how you might ask for your review will be very different from mine as well. However, there is one consensus amongst the better/nicer app developers;

For the love of all that is holy, do not inflict an alert dialog on your user when they just want to use your app!

In order to avoid the above, developers often go by the ‘Happy paths’ principle. If your user has had a couple of good experiences with your app, they might be more receptive to give a good review. So, it becomes important to keep track of your user’s good experiences. That’s what this snippet is about.

Rate Our App Helper

The Rate Our App Helper helps you to keep track of your user’s good experiences, and allows you to ask whether the user should be asked for a review now. Every useful aspect is stored in the UserDefaults, so any instance will have access to the same resources. This allows you to use a new instance every time, without having to create a singleton or to pass around an instance everywhere.

The functionality works based on three steps;

  1. When the user did something useful, do RateOurAppHelper().increaseActionCounter(). This will increase the actionCounter.
  2. When you think the time is right to ask for a review, do RateOurAppHelper().trigger(). If your actionCounter is at least equal to your actionThreshold, you will be notified to present a rating dialog to the user. What rating dialog? Where does the user go when they make a choice? That’s up to your project!
  3. If you present a rating dialog, allow the user to choose between three choices and save it with saveUserSelection(_:);
    • Write a review 🎉
    • Leave feedback ⁉️
    • Maybe later 🤷‍

The helper enforces a couple of good habits;

  • Has your user ever pressed ‘write a review’? Then don’t ask them, ever again.
  • After ‘Leave feedback’ and ‘Maybe later’, wait at least until the new app release before asking again.

If you just want to copy paste the code, check it out below! And don’t forget to add the Bundle extension. You’ll need to know the app bundle’s version number.

import Foundation

public enum UserSelection: Int {
    case noSelection  = 0
    case writeReview
    case giveFeedback
    case maybeLater
}

/// This helper keeps track of the actions that can trigger the 'Rate our app' dialog.
final public class RateOurAppHelper: NSObject {
    /// A notification with this name is posted
    public static let ShouldShowDialogNotification = "RateOurApp_ShouldShowDialogNotification"
    
    fileprivate let RateOurAppKeys_lastKnownAppVersion = "RateOurAppKeys_lastKnownAppVersion"
    fileprivate let RateOurAppKeys_actionCounter = "RateOurAppKeys_actionCounter"
    fileprivate let RateOurAppKeys_userSelection = "RateOurAppKeys_userSelection"
    
    fileprivate var userDefaults: UserDefaults
    fileprivate var bundle: NWABundle
    
    /// The number of actions needed before we should ask for a rating.
    public fileprivate(set) var actionThreshold: Int
    
    /// The last known app version the helper knows of. If this is the first time the helper is initialized, the current version will be used.
    public fileprivate(set) var lastKnownAppVersion: String {
        didSet {
            userDefaults.setValue(lastKnownAppVersion, forKey: RateOurAppKeys_lastKnownAppVersion)
        }
    }
    /// The action counter keeps track of any positive user actions, after which we _might_ ask for a rating.
    public fileprivate(set) var actionCounter: Int {
        didSet {
            userDefaults.set(actionCounter, forKey: RateOurAppKeys_actionCounter)
        }
    }
    /// The last user selection
    public fileprivate(set) var userSelection: UserSelection {
        didSet {
            userDefaults.set(userSelection.rawValue, forKey: RateOurAppKeys_userSelection)
        }
    }
    
    /// Creates a new helper. Added to make sure that `[RateOurAppHelper new]` doesn't crash the app. Less pretty though.
    convenience override init() {
        self.init(bundle: Bundle.main)
    }
    
    /// Creates a new helper.
    public required init(actionThreshold: Int = 2, userDefaults: UserDefaults = UserDefaults.standard, bundle: NWABundle = Bundle.main) {
        self.userDefaults = userDefaults
        self.bundle = bundle
        self.actionThreshold = actionThreshold
        
        if let appVersion = userDefaults.string(forKey: RateOurAppKeys_lastKnownAppVersion) {
            self.lastKnownAppVersion = appVersion
        }
        else {
            self.lastKnownAppVersion = try! bundle.appVersionNumber()
            // We forcibly ignore the thrown error, since an app can't actually be distributed without a version number.
        }
        
        if let userSelection = UserSelection(rawValue: userDefaults.integer(forKey: RateOurAppKeys_userSelection)) {
            self.userSelection = userSelection
        }
        else {
            self.userSelection = .noSelection
        }
        
        self.actionCounter = userDefaults.integer(forKey: RateOurAppKeys_actionCounter)
        
        super.init()
    }
    
    deinit {
        userDefaults.synchronize()
    }
}

extension RateOurAppHelper {
    /// Resets all values of the helper.
    public func reset() {
        lastKnownAppVersion = ""
        actionCounter = 0
        userSelection = .noSelection
        
        userDefaults.removeObject(forKey: RateOurAppKeys_lastKnownAppVersion)
        userDefaults.removeObject(forKey: RateOurAppKeys_actionCounter)
        userDefaults.removeObject(forKey: RateOurAppKeys_userSelection)
    }
    
    /// To be called every app launch, this performs the migrations that need to happen between app updates. Usually this means resetting any counters to ask for another rating.
    public func performMigration() {
        let currentAppVersion = try! bundle.appVersionNumber()
        
        if currentAppVersion != lastKnownAppVersion {
            lastKnownAppVersion = currentAppVersion
            
            actionCounter = 0
            
            if userSelection != .writeReview {
                userSelection = .noSelection
            }
        }
    }
    
    /// To be called after the user has completed a positive action. This increases the actionCounter by one.
    public func increaseActionCounter() {
        actionCounter += 1
    }
    
    /// To be called if the user is currently in a happy flow and is more likely to leave a positive review or to have some critique.
    /// This checks whether the user should be asked to rate the app. If so, sends out a notification and executes the optional handler. Either one should show the rating dialog.
    ///
    /// - parameters:
    ///   - center: The center which posts the notification. Defaults to `NSNotificationCenter.defaultCenter()`.
    ///   - handler: The handler that is executed to show the rating dialog.
    public func trigger(notificationCenter center: NotificationCenter? = NotificationCenter.default, handler: (() -> Void)? = nil) {
        if shouldAskUser() {
            center?.post(name: Notification.Name(rawValue: RateOurAppHelper.ShouldShowDialogNotification), object: nil)
            handler?()
        }
    }
    
    fileprivate func shouldAskUser() -> Bool {
        let thresholdReached = actionCounter >= actionThreshold
        
        switch userSelection {
        case .noSelection where thresholdReached:
            return true
        default:
            return false
        }
    }
    
    /// To be called after the user has made a selection. This saves the user's selection to refer to later.
    ///
    /// - parameters:
    ///   - selection: The option the user selected.
    public func saveUserSelection(_ selection: UserSelection) {
        userSelection = selection
    }
}

import Foundation

public enum NSBundleError: Error {
    case noAppVersionFound
}

public protocol NWABundle {
    func appVersionNumber() throws -> String
}

extension Bundle: NWABundle {
    fileprivate var NSBundle_AppVersionKey: String { return "CFBundleShortVersionString" }
    
    /// Returns the app's version number.
    ///
    /// - throws: A `NoAppVersionFound` error if the version number was not found.
    /// - returns: The app's version number (e.g. '2.1.0')
    public func appVersionNumber() throws -> String {
        guard let version = object(forInfoDictionaryKey: NSBundle_AppVersionKey) as? String else {
            throw NSBundleError.noAppVersionFound
        }
        return version
    }
}

If you think the snippet is useful – or have an improvement – send me tweet or a toot!