Nanashi 2.0

I released the first version of Nanashi on the App Store 7 years ago, it’s a turn-based strategy game. The game was relatively simple: a solo mode against an AI and a pass and play mode.

The most requested feature was an online multi-player mode with a leaderboard, but I never found the time to do it… until today.

I finally released the version 2.0 of Nanashi with an online mode and a leaderboard.

The app is available on the App Store: https://apps.apple.com/us/app/nanashi/id649608957

PLAYER VS ROBOT

Rules are simple:
– select your piece to see where you can play
– one square away (including the diagonals) and your piece is cloned
– two squares away and your piece jumps
– capture the pieces of your enemy by cloning or jumping next their piece
– you win if you own the most pieces at the end of the game

Play against a challenging AI or against a friend on your iPhone or iPad……or play online against strangers and rank up the leaderboard!

From zero to the App Store in one week-end

Build a complete app from scratch in a week-end? Challenge accepted! I started the project on Friday evening and submitted the app for review on the App Store on Sunday evening. It was accepted during the night and live on Monday morning.

So what was there time to do in just a weekend? Actually, a lot:

  • SwiftUI: I experimented a tiny bit of SwiftUI before, but nothing compares to building a real app!
  • GPS based location is retrieved in the background, reverse geocoded and stored in CoreData
  • Forms: automatically recording locations is cool, but you sometimes have to go and manually add or edit visits, SwiftUI makes it particularly easy to create dynamic forms with very little code and handles pretty much everything for you
  • In-App Purchase with a paywall selling a non-consumable product to unlock some features like yearly / country reports and PDF export
  • PDF generation from HTML templates, and export using a SwiftUI wrapped version UIActivityController
  • Analytics and Crashlytics integration using Firebase
  • App Store preparation: finding a name, writing description, creating a logo, preparing screenshots, submitting the In-App Purchase product
  • Submission!

What kind of issues did I run into?

  • SwiftUI is fun, but sometimes it’s just really hard to do something really simple (like presetting two modals / sheets from the same view), and sometimes you simply can’t do something simple (like removing the disclosure indicator from list cells without generating random crashes)
  • Fastlane didn’t want to work on my machine, I wanted to automate the screenshots but couldn’t, but it’s ok, since there is only one language for now and the app only support the iPhone, taking screenshots manually wasn’t too long
  • Apple randomly disabled submission from the version of Xcode available on the Mac App Store, and obviously the error message when submitting was super obscure… had to download the next coming GM from the developer download center

Is the code clean? Hell no! But was it fun to do? Absolutely! I don’t know if this app will ever sustain itself, but I’ve to admit we live in a time where very powerful tools are available for us to experiment and iterate really quickly. I’ll definitely do this kind of challenges again 🙂

Link to the app: https://apps.apple.com/us/app/trvl/id1487340379

Custom progress bar with IBDesignable and IBInspectable

When creating a custom video player, you need to have a component halfway between a UISlider, allowing your to interactively track and seek a particular position in the video, but also show progress continuously while also eventually showing buffering.

Even though there isn’t any component like this directly available in UIKit, it appears it’s fairly easy to make something like this, interactive and fully customizable:

Creating a custom view to display progress and buffer

Let’s start with the basics, we need a subclass of UIView for which we override all possible initializers:

final class CustomProgressBar: UIView {
    // MARK: - Initializers

    init() {
        fatalError("Unsupported initializer init(), use init(frame:) instead")
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    private func commonInit() {
        // TODO
    }
}

Now let’s say we want to add live preview in interface builder, we have to make it @IBDesignable and override the prepareForInterfaceBuilder:

@IBDesignable final class CustomProgressBar: UIView {
    // MARK: - Initializers

    init() {
        fatalError("Unsupported initializer init(), use init(frame:) instead")
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    private func commonInit() {
        // TODO
    }

    // MARK: - Interface builder

    #if INTERFACE_BUILDER
        override func prepareForInterfaceBuilder() {
            super.prepareForInterfaceBuilder()
            commonInit()
        }
    #endif
}

At this stage, nothing appears in interface builder yet, this is normal, we didn’t add anything to our view yet.

The progress view will be composed of several layers: the track in the background, the buffer one level up, the actual progress one level up and finally the knob or thumb.

Let’s create and add our subviews:

private lazy var trackView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.layer.masksToBounds = true
    return view
}()

private lazy var progressView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
}()

private lazy var bufferView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
}()

private lazy var thumbView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.layer.masksToBounds = true
    return view
}()

private var trackHeightConstraint: NSLayoutConstraint!
private var thumbHeightConstraint: NSLayoutConstraint!
private var progressViewWidthConstraint: NSLayoutConstraint!
private var bufferViewWidthConstraint: NSLayoutConstraint!

Then in the commonInit method:

private func commonInit() {
    // View must be the same height as intrinsicContentSize
    addConstraint(NSLayoutConstraint(item: self,
                                      attribute: .height,
                                      relatedBy: .equal,
                                      toItem: nil,
                                      attribute: .notAnAttribute,
                                      multiplier: 1.0,
                                      constant: thumbRadius))

    // Configure and add track view
    trackView.backgroundColor = trackColor
    trackView.layer.cornerRadius = trackRadius
    addSubview(trackView)
    addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[trackView]|",
                                                  options: .init(),
                                                  metrics: nil,
                                                  views: ["trackView": trackView]))
    addConstraint(NSLayoutConstraint(item: trackView,
                                      attribute: .centerY,
                                      relatedBy: .equal,
                                      toItem: self,
                                      attribute: .centerY,
                                      multiplier: 1.0,
                                      constant: 0))
    trackHeightConstraint = NSLayoutConstraint(item: trackView,
                                                attribute: .height,
                                                relatedBy: .equal,
                                                toItem: nil,
                                                attribute: .notAnAttribute,
                                                multiplier: 1.0,
                                                constant: trackHeight)
    addConstraint(trackHeightConstraint)

    // Configure and add buffer view
    bufferView.backgroundColor = bufferColor
    trackView.addSubview(bufferView)
    bufferViewWidthConstraint = NSLayoutConstraint(item: bufferView,
                                                    attribute: .width,
                                                    relatedBy: .equal,
                                                    toItem: nil,
                                                    attribute: .notAnAttribute,
                                                    multiplier: 1.0,
                                                    constant: 0.0)
    addConstraint(bufferViewWidthConstraint)
    addConstraint(NSLayoutConstraint(item: bufferView,
                                      attribute: .height,
                                      relatedBy: .equal,
                                      toItem: trackView,
                                      attribute: .height,
                                      multiplier: 1.0,
                                      constant: 0))

    // Configure and add progress view
    progressView.backgroundColor = progressColor
    trackView.addSubview(progressView)
    progressViewWidthConstraint = NSLayoutConstraint(item: progressView,
                                                      attribute: .width,
                                                      relatedBy: .equal,
                                                      toItem: nil,
                                                      attribute: .notAnAttribute,
                                                      multiplier: 1.0,
                                                      constant: 0.0)
    addConstraint(progressViewWidthConstraint)
    addConstraint(NSLayoutConstraint(item: progressView,
                                      attribute: .height,
                                      relatedBy: .equal,
                                      toItem: trackView,
                                      attribute: .height,
                                      multiplier: 1.0,
                                      constant: 0))

    // Configure and add thumb view
    addSubview(thumbView)
    thumbView.alpha = isThumbVisible ? 1.0 : 0.0
    thumbView.backgroundColor = thumbColor
    thumbView.layer.cornerRadius = thumbRadius / 2.0
    addConstraint(NSLayoutConstraint(item: thumbView,
                                      attribute: .centerY,
                                      relatedBy: .equal,
                                      toItem: self,
                                      attribute: .centerY,
                                      multiplier: 1.0,
                                      constant: 0.0))
    addConstraint(NSLayoutConstraint(item: thumbView,
                                      attribute: .width,
                                      relatedBy: .equal,
                                      toItem: thumbView,
                                      attribute: .height,
                                      multiplier: 1.0,
                                      constant: 0.0))
    thumbHeightConstraint = NSLayoutConstraint(item: thumbView,
                                                attribute: .height,
                                                relatedBy: .equal,
                                                toItem: nil,
                                                attribute: .notAnAttribute,
                                                multiplier: 1.0,
                                                constant: thumbRadius)
    addConstraint(thumbHeightConstraint)
    addConstraint(NSLayoutConstraint(item: thumbView,
                                      attribute: .centerX,
                                      relatedBy: .equal,
                                      toItem: progressView,
                                      attribute: .trailing,
                                      multiplier: 1.0,
                                      constant: 0.0))
}

The layout using auto layout, and we observe these all depend on some customizable properties, that we will mark as @IBInspectable so we can modify them directly from Interface Builder inspector:

@IBInspectable var isThumbVisible: Bool = true {
    didSet {
        thumbView.alpha = isThumbVisible ? 1.0 : 0.0
    }
}

@IBInspectable var progress: Float = 0.0 {
    didSet {
        refreshProgress()
    }
}

@IBInspectable var buffer: Float = 0.0 {
    didSet {
        refreshBuffer()
    }
}

@IBInspectable var trackColor: UIColor = UIColor.white {
    didSet {
        trackView.backgroundColor = trackColor
    }
}

@IBInspectable var trackHeight: CGFloat = 10.0 {
    didSet {
        trackHeightConstraint.constant = trackHeight
    }
}

@IBInspectable var trackRadius: CGFloat = 5.0 {
    didSet {
        trackView.layer.cornerRadius = trackRadius
    }
}

@IBInspectable var bufferColor: UIColor = UIColor.black.withAlphaComponent(0.7) {
    didSet {
        bufferView.backgroundColor = bufferColor
    }
}

@IBInspectable var progressColor: UIColor = UIColor.black {
    didSet {
        progressView.backgroundColor = progressColor
    }
}

@IBInspectable var thumbColor: UIColor = UIColor.black {
    didSet {
        thumbView.backgroundColor = thumbColor
    }
}

@IBInspectable var thumbRadius: CGFloat = 20.0 {
    didSet {
        thumbHeightConstraint.constant = thumbRadius
        thumbView.layer.cornerRadius = thumbRadius / 2.0
    }
}

All these @IBInspectable properties have default values that can be overridden in code or directly in storyboard or .xib files.

Every-time the progress or buffer changes, we have to update the constraints of some of subviews:

private func refreshProgress() {
    progressViewWidthConstraint.constant = bounds.width * CGFloat(min(max(progress, 0.0), 1.0))
}

private func refreshBuffer() {
    bufferViewWidthConstraint.constant = bounds.width * CGFloat(min(max(buffer, 0.0), 1.0))
}

Finally, it’s important to override both layoutSubviews to make sure our subviews are property placed when screen size changes (final size, rotations, etc) and the intrinsicContentSize, especially because we want the height to automatically be decided based on our track height and thumb height instead of adding a constraint ourselves:

override func layoutSubviews() {
    super.layoutSubviews()
    refreshProgress()
    refreshBuffer()
}

override var intrinsicContentSize: CGSize {
    let desiredHeight = max(trackHeight, thumbRadius)
    return CGSize(width: UIView.noIntrinsicMetric, height: desiredHeight)
}

Adding interactivity

For it to be interactive, we first need to transform our view superclass from a UIView to a UIControl:

@IBDesignable final class CustomProgressBar: UIControl {

Then, we override the touchesBegan, touchesMoved, touchesCancelled and touchesEnded methods to send basic actions:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesBegan(touches, with: event)
    sendActions(for: .touchDown)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesMoved(touches, with: event)
    guard let touch = touches.first else {
        return
    }
    let position = touch.location(in: self)
    progress = min(max(Float(position.x / bounds.width), 0.0), 1.0)
    sendActions(for: .valueChanged)
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesCancelled(touches, with: event)
    sendActions(for: .touchUpInside) // Consider this as an ended event instead
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesEnded(touches, with: event)
    sendActions(for: .touchUpInside)
}

One final touch is to make the touch zone bigger since the knob area can be fairly small:

override func point(inside point: CGPoint, with _: UIEvent?) -> Bool {
    let expandedBounds = bounds.insetBy(dx: min(bounds.width - 44.0, 0), dy: min(bounds.height - 44.0, 0))
    expandedBounds.contains(point)
    return bounds.contains(point)
}

Integration in Interface Builder

To use our custom progress bar, we first drag a new UIView then change its class in the inspector:

We now have new options for customization thanks to the @IBInspectable:

Final code

Final code can be found here: https://gist.github.com/cyrilchandelier/602afdbf23ab02e2e9a77bde7bb2a105

AVPlayer buffering

When playing a video in an AVPlayer, you sometimes want to be aware of the buffering in order to update your interface, for instance you can:

  • show an activity indicator when the player stalls due to buffering
  • create your own progress bar and show in a different color than the progression the point up to where the video is loaded

(Note that in the following examples, I consider being at the view model level and update dynamic properties that could be observed by a view controller using KVO to react and update the interface, try using reactive programming with RxSwift or Combine instead).

Detecting changes in buffer state

In order to show an activity indicator when the player stalls, we need to register 3 observers using KVO (Key-Value Observing) on the following dynamic properties of an AVPlayerItem:

  • isPlaybackBufferEmpty
  • isPlaybackBufferFull
  • isPlaybackLikelyToKeepUp
@objc private(set) dynamic var isStall: Bool = false

// MARK: - Buffering KVO

private var isPlaybackBufferEmptyObserver: NSKeyValueObservation?
private var isPlaybackBufferFullObserver: NSKeyValueObservation?
private var isPlaybackLikelyToKeepUpObserver: NSKeyValueObservation?

private func observeBuffering(for playerItem: AVPlayerItem) {
    isPlaybackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, changeHandler: onIsPlaybackBufferEmptyObserverChanged)
    isPlaybackBufferFullObserver = playerItem.observe(\.isPlaybackBufferFull, changeHandler: onIsPlaybackBufferFullObserverChanged)
    isPlaybackLikelyToKeepUpObserver = playerItem.observe(\.isPlaybackLikelyToKeepUp, changeHandler: onIsPlaybackLikelyToKeepUpObserverChanged)
}

private func onIsPlaybackBufferEmptyObserverChanged(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<Bool>) {
    if playerItem.isPlaybackBufferEmpty {
        isStall = true
    }
}

private func onIsPlaybackBufferFullObserverChanged(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<Bool>) {
    if playerItem.isPlaybackBufferFull {
        isStall = false
    }
}

private func onIsPlaybackLikelyToKeepUpObserverChanged(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<Bool>) {
    if playerItem.isPlaybackLikelyToKeepUp {
        isStall = false
    }
}

When the updates are receiving, we can then react accordingly:

  • isPlaybackBufferEmpty = true: the player has to fill the buffer, definitely stalling, this is a good place to start the activity indicator
  • isPlaybackBufferFull = true: the player has filled the buffer completely, at this stage it has more than enough to play, not stalling, the activity indicator must be stopped
  • isPlaybackLikelyToKeepUp = true: the player has filled enough of the buffer to start playing, at this stage, it will restart playing if not paused and is not stalling, the activity indicator can be stopped

Detecting up to what point of the video is buffered

In order to know and convert the loading time ranges into a percentage of the video, we will need to retrieve and extract different pieces of information:

  • the video duration
  • the available times aka what’s been loaded already

Getting video duration

For the duration of the video, again, an observer on the duration property of the AVPlayerItem and using KVO will do the trick:

@objc private(set) dynamic var duration: TimeInterval = 0.0

// MARK: - Duration KVO

private var durationObserver: NSKeyValueObservation?

private func observeDuration(for playerItem: AVPlayerItem) {
    durationObserver = playerItem.observe(\.duration, changeHandler: { [weak self] (playerItem, _) in
        self?.duration = playerItem.duration.seconds
    })
}

Receiving periodic time updates

At the AVPlayer level, we can add a periodic time observer that will call our callback as close as the requested interval as possible, in the following case every half-second:

let player = AVPlayer(playerItem: playerItem)
player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)),
                               queue: DispatchQueue.main,
                               using: handleTimeChanged)

So every 1/2 second, we enter our callback, and this is a good place to refresh our local representation of the buffer:

private func handleTimeChanged(time: CMTime) {
    refreshBuffered()
    refreshProgression(time: time)
}

Refreshing loaded buffer

The AVPlayerItem has a property called loadedTimeRanges that has everything we need. We get its timeRangeValue if it exist, and then compose the available duration.

Based on the video duration, we can transform the available duration (or buffered duration) into a percentage of the video:

@objc private(set) dynamic var buffered: Float = 0.0

private func refreshBuffered() {
    buffered = Float(availableDuration / duration)
}

private var availableDuration: Double {
    guard let timeRange = player.currentItem?.loadedTimeRanges.first?.timeRangeValue else {
        return 0.0
    }
    let startSeconds = timeRange.start.seconds
    let durationSeconds = timeRange.duration.seconds
    return startSeconds + durationSeconds
}

This is not perfect, obviously the buffer doesn’t contain the whole video data between 0.0 and availableDuration, but this is good enough to show on a UIProgressView.

Bonus: refreshing progression

Because we receive periodic time updates, it is also a good place to update our progression in the model, here after I do it in two forms:

  • currentTime (TimeInterval) to be formatted and displayed in a label
  • progress (Float) to configure a UISlider and see progress visually
@objc private(set) dynamic var currentTime: TimeInterval = 0.0
@objc private(set) dynamic var progress: Float = 0.0

private func refreshProgression(time: CMTime) {
    currentTime = time.seconds
    progress = Float(currentTime / duration)
}

Observing Low Data Mode and other expensive network paths

In iOS 13, Apple introduced Low Data Mode, an option users can enable on a per network basis to nicely ask the system and developers to consume as little data as possible.

It’s then the developer responsibility to conform to this preference. This option is easy to implement when using URLSession, but there is also a way to observe the status of the current network path, and the changes that may occur while your application or your screen is active using the Network framework:

import Network

let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
    if path.isConstrained {
        // Current network is constrained by user preference, aka iOS 13+ Low Data Mode
    } else if path.isExpensive {
        // Current network is considered expensive (eg: cellular, hotspot)
    } else {
        // Current network hasn't anything special, most likely is WiFi
    }
}
monitor.start(queue: DispatchQueue.global(qos: .background))

(Swift 5.1 / iOS 13.1)