Modelling state in Swift

- 3 mins

Some source code of truth and improvement from it

class Enemy {
    var health = 10
    var isInPlay = false
}
we have to update two above properties concurrently because when *health = 0*, *isInPlay = true*

at somewhere in our code, we have some logic to handle:

func enemyDidTakeDamage() {
    if enemy.health <= 0 {
        enemy.isInPlay = false
    }
}

or

func performSpecialAttack() {
    for enemy in allEnemies {
        enemy.health = 0
    }
}
// they forgot to update isInPlay

There is a solution or one way of solving this problem, and to make sure that to automatically update the isInPlay property, using a didSet on the health property

class Enemy {
    var health = 10 {
        didSet { putOutOfPlayIfNeeded() }
    }

    // Important to only allow mutations of this property from within this class
    private(set) var isInPlay = true

    private func putOutOfPlayIfNeeded() {
        guard health <= 0 else {
            return
        }

        isInPlay = false
        remove()
    }
}

Making states exclusive

struct Video {
    let url: URL
    var downloadTask: Task?
    var file: File?
    var isPlaying = false
    var progress: Double = 0
}
with above structure, detecting state of video is so complex as below example

if let downloadTask = video.downloadTask {
    // Handle download
} else if let file = video.file {
    // Perform playback
} else {
    // Uhm... what to do here? 🤔
}

There is a way

struct Video {
    enum State {
        case willDownload(from: URL)
        case downloading(task: Task)
        case playing(file: File, progress: Double)
        case paused(file: File, progress: Double)
    }

    var state: State
}
The way to improve comprehension of state for play
extension Video {
    struct PlaybackState {
        let file: File
        var progress: Double
    }
}

/// Use it in example 
case playing(PlaybackState)
case paused(PlaybackState)

Applying it in reality:

class VideoPlayerViewController: UIViewController {
    var video: Video {
        // Every time the video changes, we re-render
        didSet { render() }
    }

    fileprivate lazy var actionButton = UIButton()

    private func render() {
        renderActionButton()
    }

    private func renderActionButton() {
        let actionButtonImage = resolveActionButtonImage()
        actionButton.setImage(actionButtonImage, for: .normal)
    }

    private func resolveActionButtonImage() -> UIImage {
        // The image for the action button is declaratively resolved
        // directly from the video state
        switch video.state {
            // We can easily discard associated values that we don't need
            // by simply omitting them
            case .willDownload:
                return .wait
            case .downloading:
                return .cancel
            case .playing:
                return .pause
            case .paused:  
                return .play
        } 
    }

    func render() {
        renderActionButton()
        renderVideoSurface()
        renderNavigationBarButtonItems()
        ...
    }
}
private extension VideoPlayerViewController {
    func handleStateChange() {
        switch video.state {
        case .willDownload(let url):
            // Start a download task and enter the 'downloading' state
            let task = Task.download(url: url)
            task.start()
            video.state = .downloading(task: task)
        case .downloading(let task):
            // If the download task finished, start playback
            switch task.state {
            case .inProgress:
                break
            case .finished(let file):
                let playbackState = Video.PlaybackState(file: file, progress: 0)
                video.state = .playing(playbackState)
            }
        case .playing:
            player.play()
        case .paused:
            player.pause()
        }
    }
}
// This way you need to do something very specific that only affects a certain state. 

extension VideoPlayerViewController {
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        // Ideally, we'd like an API like this, that let's us cancel any ongoing
        // download task without having to write a huge switch statement
        video.downloadTask?.cancel()
    }
}
extension Video {
    var downloadTask: Task? {
        guard case let .downloading(task) = state else {
            return nil  
        }

        return task
    }
}

Summary:

comments powered by Disqus
rss facebook twitter github youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora