From error to alert with actions

Hello, Habr! To the user, the error messages often look like “Something is wrong, AAAA!”. Of course, he would like instead of mistakes to see the magic error “Repair it all”. Well, or other options. We began to actively add these to ourselves, and I want to talk about how you can do this.



First, introduce myself - my name is Alexander, the last six years I have devoted iOS development. Now I’m responsible for the ManyChat mobile application and I will solve problems using his example.

Let's immediately formulate what we will do:

  • Add Functionality to Error Type
  • Turn errors into user-friendly alerts
  • We display the possible further actions in the interface and process their clicks

And all this will be on Swift :)

We will solve the problem with an example. The server returned an error with code 500 instead of the expected 200. What should the developer do? At the very least, with sadness to inform the user - the expected post with seals could not be downloaded. In Apple, the standard pattern is alert, so let's write a simple function:

final class FeedViewController: UIViewController {
  // -   

	func handleFeedResponse(...) {
	/// -    
	if let error = error {
		let alertVC = UIAlertController(
			title: "Error",
			message: "Error connecting to the server",
			preferredStyle: .alert)
		let action = UIAlertAction(title: "OK", style: .default, handler: nil)
		alertVC.addAction(action)
		self.present(alertVC, animated: true, completion: nil)
	}
}

PS For simplicity, most of the code will be in the controller. You are free to use the same approaches in your architecture. The article code will be available in the repository , at the end of the article this link will also be.

We get the following picture:



Theoretically, we completed the task. But several things are immediately evident:

  • We did not give the opportunity to somehow switch from an erroneous scenario to a successful one. OK in the current case, it just hides the alert - and this is not a solution
  • From the point of view of user experience, the text needs to be made more clear, neutral. So that the user is not afraid and does not run to put one star in the AppStore to your application. In this case, a detailed text would be useful to us when debugging
  • And, to be honest - alerts are somewhat outdated as a solution (increasingly, dummy screens or toasts appear in applications). But this is already a question that should be discussed separately with the team





You must admit that the option presented below looks much more sympathetic. Whichever option you choose, you will need to think of such a mechanism for displaying a message that will look great when an arbitrary error occurs, offer the user a clear script for further work in the application and provide a set of actions. The solution is:

  • Must be extensible. We all know about inherent design variability. Our mechanism must be ready for anything
  • It is added to the object (and removed) in a couple of lines of code
  • Well tested

But before that, let's plunge into the theoretical minimum for errors in Swift.

Error in Swift


This paragraph is a top-level overview of errors in general. If you are already actively using your mistakes in the application, you can safely proceed to the next paragraph.

What is a mistake? Some kind of wrong action or incorrect result. Often we can assume possible errors and describe them in advance in the code.

For this case, Apple gives us the type Error. If we open the Apple documentation, then Error will look like this (relevant for Swift 5.1):

public protocol Error {
}

Just a protocol without additional requirements. The documentation kindly explains - the lack of required parameters allows any type to be used in the Swift error handling system. With such a gentle protocol, we will simply work.

The idea to use enum immediately comes to my mind: there are a finite known number of errors, they may have some kind of parameters. Which is what Apple is doing. For example, you might consider implementing a DecodingError:

public enum DecodingError : Error {
    
        /// ,     . 
    	///  ,    
        public struct Context {
    
    	    ///      
            public let codingPath: [CodingKey]
            public let debugDescription: String
    
    	    /// ,    . 
            ///      .   
            public let underlyingError: Error?
    
            public init(codingPath: [CodingKey], debugDescription: String, underlyingError: Error? = nil)
        }
    
    	/// N    
        case typeMismatch(Any.Type, DecodingError.Context)
        case valueNotFound(Any.Type, DecodingError.Context)
    
    ...

Take advantage of Apple best practices. Imagine a group of possible network errors in a simplified form:

enum NetworkError: Error {
       //  500 
	case serverError
        //   ,   
	case responseError
        //  ,   ,  
	case internetError
}

Now, anywhere in our application where the error occurs, we can use our Network.Error.

How to work with bugs? There is a do catch mechanism. If a function can throw an error, then it is marked with the throws keyword. Now each of its users is required to access it through the do catch construct. If there is no error, we will fall into the do block, with an error, into the catch block. The functions leading to the error can be any number in the do block. The only negative is that in catch we get an error of type Error. You will need to cast the error into the desired type.

As an alternative, we can use the optional, that is, get nil in case of an error and get rid of the bulky design. Sometimes it’s more convenient: let's say when we get an optional variable, and then apply a throws function to it. The code can be put in one if / guard block, and it will remain concise.

Here is an example of working with the throws function:

func blah() -> String throws {
    	throw NetworkError.serverError
    }
    
    do {
    	let string = try blah()
    	//     ,      
    	let anotherString = try blah()
    } catch {
    	//  NetworkError.serverError
    	print(error)
    }
    
    //  string = nil
    let string = try? blah()

PS Do not confuse with do catch in other languages. Swift does not throw an exception, but writes the value of the error (if it happened) in a special register. If there is a value, it goes to the error block, if not, the do block continues. Sources for the most curious: www.mikeash.com/pyblog/friday-qa-2017-08-25-swift-error-handling-implementation.html

This method is good for handling synchronous events and is not so convenient for long operations (for example, requesting data over the network), which can potentially be time consuming. Then you can use simple completion.

As an alternative to Swift 5, Result was introduced - a prepared enum that contains two options - success and failure. By itself, it does not require the use of Error. And it has no direct relation to asynchrony. But returning precisely this type to completion is more convenient for asynchronous events (otherwise you have to do two completion, success and failure, or return two parameters). Let's write an example:

func blah<ResultType>(handler: @escaping (Swift.Result<ResultType, Error>) -> Void) {
	handler(.failure(NetworkError.serverError)
}

blah<String>(handler { result in 
	switch result {
		case .success(let value):
			print(value)
		case .failure(let error):
			print(error)
	}
})

This information is quite enough for us to work.

Once again, briefly:

  • Errors in Swift is a protocol
  • It is convenient to present errors in the form of enum
  • There are two ways to deal with errors - synchronous (do catch) and asynchronous (your own competion or Result)

Error text


Let's get back to the topic of the article. In the paragraph above, we created our own type of errors. There he is:

enum NetworkError: Error {
        //  500 
	case serverError
        //   ,   
	case responseError
        //  ,   ,  
	case internetError
}

Now we need to match each error with a text that will be understandable to the user. We will display it in the interface in case of an error. LocalizedError Protocol hurries to help us. It inherits protocol Error and supplements it with 4 properties:

protocol LocalizedError : Error {
    var errorDescription: String? { get }
    var failureReason: String? { get }
    var recoverySuggestion: String? { get }
    var helpAnchor: String? { get }
}

We implement the protocol:

extension NetworkError: LocalizedError {
    	var errorDescription: String? {
            switch self {
            case .serverError, .responseError:
                return "Error"
    	    case .internetError:
                return "No Internet Connection"
            }
        }
    
        var failureReason: String? {
            switch self {
            case .serverError, .responseError:
                return "Something went wrong"
    	    case .internetError:
                return nil
            }
        }
    
        var recoverySuggestion: String? {
            switch self {
            case .serverError, .responseError:
                return "Please, try again"
    	    case .internetError:
                return "Please check your internet connection and try again"
            }
        }
    }

The error display will hardly change:

	if let error = error {
		let errorMessage = [error.failureReason, error.recoverySuggestion].compactMap({ $0 }).joined(separator: ". ")
		let alertVC = UIAlertController(
			title: error.errorDescription,
			message: errorMessage,
			preferredStyle: .alert)
		let action = UIAlertAction(title: "OK", style: .default) { (_) -> Void in }
		alertVC.addAction(action)
		self.present(alertVC, animated: true, competion: nil)

Great, everything was easy with the text. Let's move on to the buttons.

Error recovery


Let's present the error handling algorithm in a simple diagram. For a situation where, as a result of an error, we show a dialog box with Try Again, Cancel options and, possibly, some specific ones, we get the scheme:



We will start solving the problem from the end. We need a function that shows an alert with n + 1 options. We throw, as we would like to show an error:

struct RecovableAction {
    	let title: String
    	let action: () -> Void
    }
    
    func showRecovableOptions(actions: [RecovableAction], from viewController: UIViewController) {
    	let alertActions = actions.map { UIAlertAction(name: $0.title, action: $0.action) }
    	let cancelAction = UIAlertAction(name: "Cancel", action: nil)
    	let alertController = UIAlertController(actions: alertActions)
    	viewController.present(alertController, complition: nil)
    }

A function that determines the type of error and transmits a signal to display an alert:

func handleError(error: Error) {
	if error is RecovableError {
		showRecovableOptions(actions: error.actions, from: viewController)
		return
	}
	showErrorAlert(...)
} 


And an extended type of error, which has a context and understanding of what to do with this or that option.

struct RecovableError: Error {
	let recovableACtions: [RecovableAction]
	let context: Context
}

The head immediately draws a diagram of your bike. But first, let's check the Apple docks. Perhaps part of the mechanism is already in our hands.

Native implementation?


A bit of internet searching will result in protocol RecoverableError :

// A specialized error that may be recoverable by presenting several potential recovery options to the user.
protocol RecoverableError : Error {
    var recoveryOptions: [String] { get }

    func attemptRecovery(optionIndex recoveryOptionIndex: Int, resultHandler handler: @escaping (Bool) -> Void)
    func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool
}

It looks like we are looking for:

  • recoveryOptions: [String] - a property that stores recovery options
  • func attemptRecovery (optionIndex: Int) -> Bool - restores from an error, synchronously. True - On Success
  • func attemptRecovery (optionIndex: Int, resultHandler: (Bool) -> Void) - Asynchronous option, the idea is the same

With usage guides, everything is more modest. A small search on the Apple site and the surrounding area leads to an article on error handling written before Swift’s public announcements.

Briefly:

  • The mechanism is thought up for MacOs applications and shows a dialog box
  • It was originally built around NSError.
  • The RecoveryAttempter object is encapsulated inside the error in userInfo, which knows about the conditions of the error and can choose the best solution to the problem. Object must not be nil
  • RecoveryAttempter must support the informal protocol NSErrorRecoveryAttempting
  • Also in userInfo should be recovery option
  • And everything is tied to calling the presentError method, which is only in the macOS SDK. He shows an alert
  • If the alert is shown through presentError, then when you select an option in the pop-up window in AppDelegate, an interesting function twitches:

func attemptRecovery(fromError error: Error, optionIndex recoveryOptionIndex: Int, delegate: Any?, didRecoverSelector: Selector?, contextInfo: UnsafeMutableRawPointer?)

But since we do not have presentError, we cannot pull it.



At this point, it feels like we dug up a corpse rather than a treasure. We will have to turn Error into NSError and write our own function to display the alert by the application. A bunch of implicit connections. It is possible, difficult and not entirely clear - “Why?”.

While the next cup of tea is brewing, one might wonder why the function above uses delegate as Any and passes the selector. The answer is below:

Answer
iOS 2. ! ( , ). :)

Building a bike


Let's implement the protocol, it won’t hurt us:

struct RecoverableError: Foundation.RecoverableError {
	let error: Error
	var recoveryOptions: [String] {
		return ["Try again"]s
	}
	
	func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool {
		//  ,    
		return true
	}
	
	func attemptRecovery(optionIndex: Int, resultHandler: (Bool) -> Void) {
		//      . 
               //      
		switch optionIndex {
			case 0:
				resultHandler(true)
			default: 
				resultHandler(false)
	}
}

Index dependency is not the most convenient solution (we can easily go beyond the array and crash the application). But for MVP will do. Take the idea of ​​Apple, just modernize it. We need a separate Attempter object and button options that we will give it:

struct RecoveryAttemper {
    	//   
    	private let _recoveryOptions: [RecoveryOptions]
    
    	var recoveryOptionsText: [String] {
    		return _recoveryOptions.map({ $0.title })
    	}
    
    	init(options: [RecoveryOptions] {
    		_recoveryOptions = recoveryOptions
    	}
    
    	//    
    	func attemptRecovery(fromError error: Error, optionIndex: Int) -> Bool {
    		let option = _recoveryOptions[optionIndex]
    				switch option {
    				case .tryAgain(let action)
    					action()
    					return true
    				case .cancel:
    					return false
    				}
    		}
    }
    
    //  enum,       
    enum RecoveryOptions {
    	//      (,     )
    	case tryAgain(action: (() -> Void))
    	case cancel
    }

Now you need to show the error. I really like protocols, so I will solve the problem through them. Let's create a universal protocol for creating a UIAlertController from errors:

protocol ErrorAlertCreatable: class, ErrorReasonExtractable {
    	//     
    	func createAlert(for error: Error) -> UIAlertController
    }
    
    // MARK: - Default implementation
    extension ErrorAlertCreatable where Self: UIViewController {
    	func createAlert(for error: Error) -> UIAlertController {
    		//      
    		if let recoverableError = error as? RecoverableError {
    			return createRecoverableAlert(for: recoverableError)
    		}
    		let defaultTitle = "Error"
    		let description = errorReason(from: error)
    
    		//          
    		if let localizedError = error as? LocalizedError {
    			return createAlert(
    				title: localizedError.errorDescription ?? defaultTitle,
    				message: description,
    				actions: [.okAction],
    				aboveAll: aboveAll)
    		}
    
    		return createAlert(title: defaultTitle, message: description, actions: [.okAction])
    	}
    
    	fileprivate func createAlert(title: String?, message: String?, actions: [UIAlertAction]) -> UIAlertController {
    		let alertViewController = UIAlertController(title: title, message: message, preferredStyle: .alert)
    		actions.forEach({ alertViewController.addAction($0) })
    		return alertViewController
    	}
    
    	fileprivate func createRecoverableAlert(for recoverableError: RecoverableError) -> UIAlertController {
    		let title = recoverableError.errorDescription
    		let message = recoverableError.recoverySuggestion
    		//     . 
    		let actions = recoverableError.recoveryOptions.enumerated().map { (element) -> UIAlertAction in
    		let style: UIAlertAction.Style = element.offset == 0 ? .cancel : .default
    		return UIAlertAction(title: element.element, style: style) { _ in
    			recoverableError.attemptRecovery(optionIndex: element.offset)
    		      }
    		}
    		return createAlert(title: title, message: message, actions: actions)
    	}
    
    	func createOKAlert(with text: String) -> UIAlertController {
    		return createAlert(title: text, message: nil, actions: [.okAction])
    	}
    }
    
    extension ERror
    
    //     ok
    extension UIAlertAction {
    	static let okAction = UIAlertAction(title: "OK", style: .cancel) { (_) -> Void in }
    }
    
    //    
    protocol ErrorReasonExtractable {
    	func errorReason(from error: Error) -> String?
    }
    
    // MARK: - Default implementation
    extension ErrorReasonExtractable {
    	func errorReason(from error: Error) -> String? {
    		if let localizedError = error as? LocalizedError {
    			return localizedError.recoverySuggestion
    		}
    		return "Something bad happened. Please try again"
    	}
    }

And the protocol for showing created alerts:

protocol ErrorAlertPresentable: class {
	func presentAlert(from error: Error)
}

// MARK: - Default implementation
extension ErrorAlertPresentable where Self: ErrorAlertCreatable & UIViewController {
	func presentAlert(from error: Error) {
		let alertVC = createAlert(for: error)
		present(alertVC, animated: true, completion: nil)
	}
}

It turned out to be cumbersome, but manageable. We can create new ways of showing an error (for example, toast or showing a custom view) and register default implementation without changing anything in the called method.

Suppose if our view were covered by a protocol:

protocol ViewControllerInput: class {
     //  
    }
    extension ViewControllerInput: ErrorAlertPresentable { }
    
    extension ViewController: ErrorAlertCreatable { }
    //  ,         ,    
    //    
    
    //       "",    ErrorAlertPresentable      . 
    extension ViewController: ErrorToastCreatable { }

But our example is much simpler, so we support both protocols and run the application:

func requestFeed(...) {
    		service.requestObject { [weak self] (result) in
    			guard let `self` = self else { return }
    			switch result {
    			case .success:
    				break
    			case .failure(let error):
    				//           
    				// -     (   viewController)  ,
    				//    .     tryAgainOption 
    				let tryAgainOption = RecoveryOptions.tryAgain {
    					self.requestFeed(...)
    				}
    				let recoveryOptions = [tryAgainOption]
    				let attempter = RecoveryAttemper(recoveryOptions: recoveryOptions)
    				let recovableError = RecoverableError(error: error, attempter: attempter)
    				self.presentAlert(from: recovableError)
    			}
    		}
    	}
    
    // MARK: - ErrorAlertCreatable
    extension ViewController: ErrorAlertCreatable { }
    
    // MARK: - ErrorAlertPresentable
    extension ViewController: ErrorAlertPresentable { }



It seems that everything worked out. One of the initial conditions was in 2-3 lines. We will expand our attempter with a convenient constructor:

struct RecoveryAttemper {
    	//
    	...
    	//
    	static func tryAgainAttempter(block: @escaping (() -> Void)) -> Self {
    		return RecoveryAttemper(recoveryOptions: [.cancel, .tryAgain(action: block)])
    	}
    }
    
    
    func requestFeed() {
    		service.requestObject { [weak self] (result) in
    			guard let `self` = self else { return }
    			switch result {
    			case .success:
    				break
    			case .failure(let error):
    				//    
    				let recovableError = RecoverableError(error: error, attempter: .tryAgainAttempter(block: {
    					self.requestFeed()
    				}))
    				self.presentAlert(from: recovableError)
    			}
    		}
    	}

We got the MVP solution, and it will not be difficult for us to connect and call it anywhere in our application. Let's start checking edge cases and scalability.

What if we have several exit scenarios?


Suppose a user has a repository in our application. The vault has a place limit. In this case, the user has two scenarios for exiting the error: the user can either free up space or buy more. We will write the following code:

//  
    func runOutOfSpace() {
    		service.runOfSpace { [weak self] (result) in
    			guard let `self` = self else { return }
    			switch result {
    			case .success:
    				break
    			case .failure(let error):
    				let notEnoughSpace = RecoveryOptions.freeSpace {
    					self.freeSpace()
    				}
    
    				let buyMoreSpace = RecoveryOptions.buyMoreSpace {
    					self.buyMoreSpace()
    				}
    				let options = [notEnoughSpace, buyMoreSpace]
    				let recovableError = RecoverableError(error: error, attempter: .cancalableAttemter(options: options))
    				self.presentAlert(from: recovableError)
    			}
    		}
    	}
    
    	func freeSpace() {
    		let alertViewController = createOKAlert(with: "Free space selected")
    		present(alertViewController, animated: true, completion: nil)
    	}
    
    	func buyMoreSpace() {
    		let alertViewController = createOKAlert(with: "Buy more space selected")
    		present(alertViewController, animated: true, completion: nil)
    	}
    
    
    struct RecoveryAttemper {
    	//
    	...
    	//
    	static func cancalableAttemter(options: [RecoveryOptions]) -> Self {
    		return RecoveryAttemper(recoveryOptions: [.cancel] + options)
    	}
    }



This was easily dealt with.

If we want to show not an alert, but an information view in the middle of the screen?




A couple of new protocols by analogy will solve our problem:

protocol ErrorViewCreatable {
    	func createErrorView(for error: Error) -> ErrorView
    }
    
    // MARK: - Default implementation
    extension ErrorViewCreatable {
    	func createErrorView(for error: Error) -> ErrorView {
    		if let recoverableError = error as? RecoverableError {
    			return createRecoverableAlert(for: recoverableError)
    		}
    
    		let defaultTitle = "Error"
    		let description = errorReason(from: error)
    		if let localizedError = error as? LocalizedError {
    			return createErrorView(
    				title: localizedError.errorDescription ?? defaultTitle,
    				message: description)
    		}
    		return createErrorView(title: defaultTitle, message: description)
    		}
    
    	fileprivate func createErrorView(title: String?, message: String?, actions: [ErrorView.Action] = []) -> ErrorView {
    		//  ErrorView        . 
                //     github
    		return ErrorView(title: title, description: message, actions: actions)
    	}
    
    	fileprivate func createRecoverableAlert(for recoverableError: RecoverableError) -> ErrorView {
    		let title = recoverableError.errorDescription
    		let message = errorReason(from: recoverableError)
    		let actions = recoverableError.recoveryOptions.enumerated().map { (element) -> ErrorView.Action in
    			return ErrorView.Action(title: element.element) {
    				recoverableError.attemptRecovery(optionIndex: element.offset)
    			}
    		}
    		return createErrorView(title: title, message: message, actions: actions)
    	}
    }

protocol ErrorViewAddable: class {
    	func presentErrorView(from error: Error)
    
    	var errorViewSuperview: UIView { get }
    }
    
    // MARK: - Default implementation
    extension ErrorViewAddable where Self: ErrorViewCreatable {
    	func presentErrorView(from error: Error) {
    		let errorView = createErrorView(for: error)
    		errorViewSuperview.addSubview(errorView)
    		errorView.center = errorViewSuperview.center
    	}
    }
    
    
    //    
    
    // MARK: - ErrorAlertCreatable
    extension ViewController: ErrorViewCreatable { }
    
    // MARK: - ErrorAlertPresentable
    extension ViewController: ErrorViewAddable {
    	var errorViewSuperview: UIView {
    		return self
    	}
    }

Now we can show errors in the form of an information view. Moreover, we can decide how to show them. For example, the first time you enter the screen and the error - show information view. And if the screen loaded successfully, but the action on the screen returned an error - show an alert.

If there is no access to the view?


Sometimes you need to throw an error, but there is no access to the view. Or we don’t know which view is currently active, and we want to show an alert on top of everything. How to solve this problem?

One of the easiest ways (in my opinion) to do the same thing as Apple does with the keyboard. Create a new Window on top of the current screen. Let's do it:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

	//   –   . 
	//              DI
	static private(set) var errorWindow: UIWindow = {
		let alertWindow = UIWindow.init(frame: UIScreen.main.bounds)
		alertWindow.backgroundColor = .clear

		//  rootViewController,    present   viewController
		let viewController = UIViewController()
		viewController.view.backgroundColor = .clear
		alertWindow.rootViewController = viewController

		return alertWindow
	}()

Create a new alert that can show on top of everything:

final class AboveAllAlertController: UIAlertController {
	var alertWindow: UIWindow {
		return AppDelegate.alertWindow
	}

	func show() {
		let topWindow = UIApplication.shared.windows.last
		if let topWindow = topWindow {
			alertWindow.windowLevel = topWindow.windowLevel + 1
		}

		alertWindow.makeKeyAndVisible()
		alertWindow.rootViewController?.present(self, animated: true, completion: nil)
	}

	override func viewWillDisappear(_ animated: Bool) {
		super.viewWillDisappear(animated)

		alertWindow.isHidden = true
	}
}

protocol ErrorAlertCreatable: class {
    	//      
    	func createAlert(for error: Error, aboveAll: Bool) -> UIAlertController
    }
    
    // MARK: - Default implementation
    extension ErrorAlertCreatable where Self: UIViewController {
    	...
    	//     
    	fileprivate func createAlert(title: String?, message: String?, actions: [UIAlertAction], aboveAll: Bool) -> UIAlertController {
    		let alertViewController = aboveAll ?
    			AboveAllAlertController(title: title, message: message, preferredStyle: .alert) :
    			UIAlertController(title: title, message: message, preferredStyle: .alert)
    		actions.forEach({ alertViewController.addAction($0) })
    		return alertViewController
    	}
    }
    
    //     
    protocol ErrorAlertPresentable: class {
    	func presentAlert(from error: Error)
    	func presentAlertAboveAll(from error: Error)
    }
    
    // MARK: - Default implementation
    extension ErrorAlertPresentable where Self: ErrorAlertCreatable & UIViewController {
    	func presentAlert(from error: Error) {
    		let alertVC = createAlert(for: error, aboveAll: false)
    		present(alertVC, animated: true, completion: nil)
    	}
    
    	func presentAlertAboveAll(from error: Error) {
    		let alertVC = createAlert(for: error, aboveAll: true)
    		//         
    		if let alertVC = alertVC as? AboveAllAlertController {
    			alertVC.show()
    			return
    		}
    		//    ,  - 
    		assert(false, "Should create AboveAllAlertController")
    		present(alertVC, animated: true, completion: nil)
    	}
    }



In appearance, nothing has changed, but now we have gotten rid of the hierarchy of view controllers. I highly recommend not getting carried away with this opportunity. It is better to call the display code in a router or entity with the same rights. In the name of transparency and clarity.

We gave users a great tool for spamming servers during malfunctions, maintenance, etc. What can we improve?

Minimum request time


Suppose we turn off the Internet and try again. Run the loader. The answer will come instantly and get a mini-game "Clicker". With blinking animation. Not too nice.



Let's turn an instant error into a process. The idea is simple - we will make the minimum request time. Here the implementation depends on your approach to networking. Suppose I use Operation, and for me it looks like this:

final class DelayOperation: AsyncOperation {
	private let _delayTime: Double

	init(delayTime: Double = 0.3) {
		_delayTime = delayTime
	}
	override func main() {
		super.main()

		DispatchQueue.global().asyncAfter(deadline: .now() + _delayTime) {
			self.state = .finished
		}
	}
}
		
// -  
let flowListOperation = flowService.list(for: pageID, path: path, limiter: limiter)
let handler = createHandler(for: flowListOperation)
let delayOperation = DelayOperation(delayTime: 0.5)
///  >>>  addDependency. 
[flowListOperation, delayOperation] >>> handler
operationQueue.addOperations([flowListOperation, delayOperation, handler])

For the general case, I can offer this design:

//  global      
DispatchQueue.global().asyncAfter(deadline: .now() + 0.15) {
    // your code here
}

Or we can make an abstraction over our asynchronous actions and add manageability to it:

struct Task {
    	let closure: () -> Void
    
    	private var _delayTime: Double?
    
    	init(closure: @escaping () -> Void) {
    		self.closure = closure
    	}
    
    	fileprivate init(closure: @escaping () -> Void, time: Double) {
    		self.closure = closure
    		_delayTime = time
    	}
    
    	@discardableResult
    	func run() -> Self {
    		if let delayTime = _delayTime {
    			DispatchQueue.global().asyncAfter(deadline: .now() + delayTime) {
    				self.closure()
    			}
    			return self
    		}
    		closure()
    		return self
    	}
    
    	func delayedTask(time: Double) -> Self {
    		return Task(closure: closure, time: time)
    	}
    }
    
    //    
    func requestObject(completionHandler: @escaping ((Result<Bool, Error>) -> Void)) -> Task {
    		return Task {
    			completionHandler(.failure(NetworkError.internetError))
    		}
    			.delayedTask(time: 0.5)
    		.run()
    	}

Now our animation will not seem so sharp, even when offline. I recommend using this approach in most places with animation.



For airplane mode, it’s good to show an alert prompt (the user could forget to turn off the mode to start working with the application). As, say, makes a telegram. And for important queries, it’s good to repeat several times under the hood before showing an alert ... But more about that another time :)

Testability


When all the logic is dumped in the viewController (as we have now), it is difficult to test. However, if your viewController is shared with business logic, testing becomes a trivial task. With a flick of the arm pants, business logic turns into:

func requestFeed() {
		service.requestObject { [weak self] (result) in
			guard let `self` = self else { return }
			switch result {
			case .success:
				break
			case .failure(let error):
				DispatchQueue.main.async {
					let recoverableError = RecoverableError(error: error, attempter: .tryAgainAttempter(block: {
						self.requestFeed()
					}))
					//     
					self.viewInput?.presentAlert(from: recoverableError)
				}
			}
		}
	}

// -  
func testRequestFeedFailed() {
	// Put out mock that conform to AlertPresntable protocol
	controller.viewInput = ViewInputMock()

	//  .    ,    
	//    expectation
	controller.requestFeed()

	// Our mocked object should save to true to bool variable when method called
	XCTAssert(controller.viewInput.presentAlertCalled)
	// Next we could compare recoverable error attempter to expected attempter
}

Together with this article we:

  • Made a convenient mechanism for displaying alerts
  • Gave users the option to retry an unsuccessful operation
  • And tried to improve the user experience with our application

→  Link to the code

Thank you all for your time, I will be glad to answer your questions in the comments.

All Articles