If an action is triggered during other action’s mutate, the action is handled in other loop.

case .tapDeleteButton(let model):
          let deleteHandler: () -> Void = { [weak self, weak listAdapter] in
            self?.action.onNext(.deletePositionCell(model?.userCash))

            listAdapter?.performUpdates(animated: true, completion: { [weak self] _ in // line 1
              self?.action.onNext(.delete(model?.userCash))
            })
          }
          self?.action.onNext(.showDeleteAlert(deleteHandler))
        }
case .showDeleteAlert(let deleteAction):
            guard let deleteAction = deleteAction else { return .empty() }
            deleteAction()
            return .empty()
case .deletePositionCell(let userCash):
            return .just(.deletePositionCell(userCash)) // line 2

line 1 is called before line 2.

IGListKit reorder by drag and drop

  1. return true to canMoveItem in cell sectionController
    override func canMoveItem(at index: Int) -> Bool {
        return true
    }

2. detect longPressGesture on View Controller

    let collectionViewUpdateQueue = BlockableTaskQueue()

func bind() {
        collectionView.rx
            .longPressGesture(configuration: { _, delegate in
                delegate.simultaneousRecognitionPolicy = .never
                delegate.beginPolicy = .custom { [weak self] gesture -> Bool in
                    guard let collectionView = self?.collectionView else { return false }
                    let touchLocation = gesture.location(in: collectionView)
                    guard let selectedIndexPath = collectionView.indexPathForItem(at: touchLocation) else { return false }
                    guard let cell = collectionView.cellForItem(at: selectedIndexPath) as? PositionListPositionCell else { return false }
                    return cell.isMovable
                }
            })
            .subscribe(onNext: { [weak self] gesture in
                self?.handleGesture(gesture)
            })
            .disposed(by: disposeBag)
}

extension PositionListStockViewController {
    func handleGesture(_ gesture: UILongPressGestureRecognizer) {
        switch gesture.state {
        case .began:
            let touchLocation = gesture.location(in: collectionView)
            guard let selectedIndexPath = collectionView.indexPathForItem(at: touchLocation) else { return }
            collectionViewUpdateQueue.block()
            collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
        case .changed:
            let position = gesture.location(in: collectionView)
            collectionView.updateInteractiveMovementTargetPosition(position)
        case .ended:
            collectionView.endInteractiveMovement()
            collectionViewUpdateQueue.unblock()
        default:
            collectionView.cancelInteractiveMovement()
            collectionViewUpdateQueue.unblock()
        }
    }
}

3. limit removable index from start to end

init() {
         let (collectionView, layout) = ViewFactory.collectionViewAndLayout(isHorizontal: false)
        layout.reorderingDelegate = self
}

extension PositionListStockViewController: CollectionViewFlowLayoutWithReorderingDelegate {
    func collectionViewFlowLayoutWithReorderingOverrideTargetIndex(moveFrom originalIndexPath: IndexPath, to computedTargetIndexPath: IndexPath) -> IndexPath {
        guard let reactor = reactor else { return originalIndexPath }

        guard let minIndexPath = reactor.getStartIndexPathOfPositions(),
              let maxIndexPath = reactor.getEndIndexPathOfPositions() else { return originalIndexPath }
        return computedTargetIndexPath.clamped(min: minIndexPath, max: maxIndexPath)
    }
}
extension PositionListStockViewReactor {
    func getStartIndexPathOfPositions() -> IndexPath? {
        guard let positions = currentState.positions,
              positions.isNotEmpty,
              let sectionIndex = makeListSection().firstIndex(where: {
                  guard let model = $0 as? DiffableBox<PositionListPositionModel> else {
                      return false
                  }
                  return true
              })
        else {
            return nil
        }
        return IndexPath(row: 0, section: sectionIndex)
    }

    func getEndIndexPathOfPositions() -> IndexPath? {
        guard let startIndex = getStartIndexPathOfPositions() else { return nil }
        let numberOfPositions = currentState.positions?.count ?? 0
        return IndexPath(row: 0, section: startIndex.section + numberOfPositions)
    }
}

4. Change list object first and then reorder request to server

init() {
        adapter.moveDelegate = self
}

extension PositionListStockViewController: ListAdapterMoveDelegate {
    func listAdapter(_ listAdapter: ListAdapter, move object: Any, from previousObjects: [Any], to objects: [Any]) {
        guard let object = object as? DiffableBox<PositionListPositionModel> else {
            return
        }
        let previous = previousObjects
            .compactMap { object in
                return object as? DiffableBox<PositionListPositionModel>
            }
        let current = objects
            .compactMap { object in
                return object as? DiffableBox<PositionListPositionModel>
            }

        reactorNotNil?.action.onNext(.onlyChangePositionsTo(current.compactMap(\.value.userStock))) 
        reactorNotNil?.action.onNext(.reorder(newList: current.compactMap(\.value.userStock)))
    }
}

5. When updating the collection view, enque to the collectionViewUpdateQueue

reactor.state.distinctUntilChanged { $0.listVersion }
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
self?.collectionViewUpdateQueue.enqueue(
BlockableTaskQueue.Mutation(
type: .wholeUpdate,
mutate: { [weak self] in self?.adapter.performUpdates(animated: false, completion: nil) }
)
)
})
.disposed(by: disposeBag)

Monitor server response errors using Crashlytics

  1. Throw various errors when parsing response
class ModelUtil {
    // 1. 응답이 JSONObject나 JSONArray가 아님
    // 2. 응답의 전체를 감싸는 key값으로 parsing이 안됨
    // 3. key값인 Array 각각이 JSONObject가 아님
    @discardableResult
    static func apiFormatError() -> NSError{
        let error = NSError(domain:"com.companyname.appname.API JSON Format Error", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
        return error
    }
    @discardableResult
    static func apiFormatError(userInfo: Dictionary<String, Any>) -> NSError{
        let error = NSError(domain:"com.companyname.appname.API JSON Format Error", code:0, userInfo:userInfo)
        Crashlytics.sharedInstance().recordError(error)
        return error
    }
    
    // 필수로 받아와야 하는 값이 parsing되지 않음
    @discardableResult static func apiEssentialValueParsingError() -> NSError {
        let error = NSError(domain:"com.companyname.appname.API JSON Essential Value Parsing Error", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
        return error
    }
    
    // 1. parsing된 값이 empty임
    // 2. parsing된 값이 상황상 null이면 안 되는데 null임
    static func apiEmptyValueError() {
        let error = NSError(domain:"com.companyname.appname.API JSON Empty Parsed Value Error", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
    }
    
    // 요구사항에서 받기로 했던 length와 API 결과 length가 다를 때
    static func apiLengthError() {
        let error = NSError(domain:"com.companyname.appname.API JSON Length is Not Expected", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
    }
}

enum ModelError: Error {
    case invalidFormat(data: [String: Any])
}

Model class

extension IngredientPack {
    static func getIngredientPack(_ requestManager: RequestManager, type: PackType, completion: @escaping (RequestResult<IngredientPack>) -> Void) {
        let url = CustomRequest.domainAppend("APIPath")!
        requestManager.request(url: url, parameters: ["type": type.rawValue]) { (result) in
            switch result {
            case .success(let data):
                do {
                    let ingredientPacks = try JSONDecoder().decode([IngredientPack].self, from: data)
                    guard let ingredientPack = ingredientPacks.first else {
                        throw ModelUtil.apiFormatError()
                    }
                    completion(.success(ingredientPack))
                } catch {
                    completion(.failure(.others))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

2. Throw errors when the status code is not normal

func parseResponse(request: URLRequest, _ error: Error?, _ response: URLResponse?, _ data: Data?, completion: @escaping (RequestResult<Data>) -> Void) {
        // Network Indicator Control.
        self.networkIndicatorCount -= 1
        
        // Handling Error.
        if let error = error {
            ResponseError.cocoaError(request: request, error: error)
            completion(.failure(.others))
            
            // Handling Response Data.
        } else {
            if let httpURLResponse = response as? HTTPURLResponse {
                if httpURLResponse.statusCode != 403
                    && (httpURLResponse.statusCode < 200
                        || httpURLResponse.statusCode >= 400){
                    let statusCodeError = ResponseError.statusCodeError(statusCode: httpURLResponse.statusCode, request: request)
                    Crashlytics.sharedInstance().recordError(statusCodeError)
                    completion(.failure(.others))
                    return
                }
            }
            
            // (1) Data 형식이 JSON형태가 아닐 경우 Error 처리.
            // (2) Data 형식이 JSON형태이고 값이 UserAuthError인 경우 Error 처리.
            // (3) Data 형식이 JSON형태이고 값이 UserAuthError가 아니면 success처리.
            if let data = data, let response = response {
                if (data.count > 0) {
                    // TODO: UserAuthError를 확인하는 방법으로 Result접근이 아니라 StatusCode로 분별하기.
                    do {
                        let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
                        
                        // 인증 에러 인 경우 JSON형식으로 UserAuthError라는 Key에 Message값을 보냄.
                        if let json = jsonObject as? [String: Any],
                            let authErrorMessage = json["UserAuthError"] as? String {
                            ResponseError.userAuthError(request: request, response: response, json: json, authErrorMessage: authErrorMessage)
                            
                            completion(.failure(.authenticationFailed(message: authErrorMessage)))
                            return // .success가 호출되지 않도록 return
                        }
                    } catch {
                        let nsError = error as NSError
                        var userInfo = nsError.userInfo
                        let responseData = String(data: data, encoding: .utf8).or("nil")
                        userInfo.updateValue(responseData, forKey: "responseData")
                        let error = NSError(domain: nsError.domain, code: nsError.code, request: request, userInfo: userInfo)
                        Crashlytics.sharedInstance().recordError(error)
                        completion(.failure(.others))
                        return // .success가 호출되지 않도록 return
                    }
                }
                completion(.success(data))
                // Handling No Data.
            } else {
                ResponseError.noData(request: request)
                completion(.failure(.others))
                
                // TODO: CustomRequest.
                // 정상적인 상황에서도 response가 없는 요청도 있기 때문에 completeHandler가 있는 요청에 대해서만 (response를 바라는데 error 가 발생 한 경우) 에러를 리포팅 한다.
                // Response가 없는 요청 알아둘 것.
            }
        }
    }

3. Third party login error

@objc func login(on viewController: UIViewController) {
        if let currentToken = AccessToken.current {
            if let tracker = loginEventTracker {
                let loginResult = LoginManagerLoginResult(token: currentToken, isCancelled: false, grantedPermissions: [], declinedPermissions: [])
                tracker(loginResult, nil)
            } else {
                fetchUser(from: viewController)
            }
        } else {
            loginManager.logIn(permissions: MyFacebook.permissions, from: viewController) { (result, error) in
                if let error = error {
                    Alert.showConfirmAlert(withTitle: self.errorTitle, message: error.localizedDescription, viewController: viewController)
                    Crashlytics.sharedInstance().recordError(error)
                    self.thirdPartyLoginDelegate?.logFail(registerType: MyFacebook.registerType, message: self.errorMessage)
                } else if result?.isCancelled == false {
                    self.fetchUser(from: viewController)
                }
            }
        }
    }

Add ///@mockolo to certain file of the API repository

Method 1: Make mockable annotation to certain files

  1. make bash file
#!/bin/bash
sed -i '' '12i\
/// @mockable' File.swift

12 is line number

2. edit Makefile

all: mockable

mockable:
	./mockable.bash

3.

$PROJECT_DIR/third_party/mockolo -s Pods/Backend -d Neo/Generated/MockAPI.swift

Method 2: Make mockable annotations to all files and that mockolo to read certain files

  1. Edit api.mustache file
/// @mockable you need to include the file to ios/scripts/generate-mock.bash to make Mock.
public protocol {{apiPrefix}}{{classname}}Type {

2.

$PROJECT_DIR/third_party/mockolo -srcs Pods/Backend/social/Classes/Swaggers/APIs/CertainAPI.swift -d [project]/Generated/MockAPI.swift