Do not bind for not changing value. Use initialState
ViewController
init(reactor: Reactor) {
self._reactor = reactor
super.init(nibName: nil, bundle: nil)
titleLabel.text = reactor.initialState.title
}
iOS Developer
Do not bind for not changing value. Use initialState
ViewController
init(reactor: Reactor) {
self._reactor = reactor
super.init(nibName: nil, bundle: nil)
titleLabel.text = reactor.initialState.title
}
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.
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)
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)
}
}
}
}
navigationController?.interactivePopGestureRecognizer?.delegate = nil
xcuserdata
*.xcworkspace
Pods
.env
When making features of a application, it’s important to evaluate the result of the feature. It’s beneficial to keep track of how well the team is working and motivate teammates in the perspective of their impact on the users.
The most important tool is AB Test. Now I’m using Statisig for AB Testing tool. There are some categories of things to measure.
However AB Test is not always possible to be accompanied by all features. For this case, there are some sideways. But because the result is dependent on the time, it can be affected by other things aside the feature. For finance app, some events that occurred during the time of the measurement can affect the results. And also if there were other features released in the meantime of the measurement, the result can be affected. Anyway, there are some ways to measure in this case. It’s quite similar to the things when there was an AB Test.
++
For making statistics requirement, it’s important to have consistency of what to log. Plus it’s good to collect all users tapping data from start. At least if you have the logs of tap events, it can imply any screen exposures.
Method 1: Make mockable annotation to certain files
#!/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
/// @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
I have a special enum only for analytics. The analytics requirements are all in snake_case. I want to make enum cases by copy and paste the requirements preserving snake_case. But sometimes I habitually make cases camelCase when I forget to copy and paste. Therefore I made a script that makes cases snake_case.
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
#go to project directory
cd $SCRIPT_DIR/..
sed -i '' '/^ *case [^\.]*$/s/\([a-z0-9]\)\([A-Z]\)/\1_\2/g' [projet_name]/[path]/[filename].swift
“-i” is to edit the file
“^” starts with
” *” multiple blanks
[^\.]*$ does not contain .
s/A/B substitute A to B
/g is to use \1 and \2
” it’s needed for Mac
extension PredefinedString {
var snakeCasedValue: String {
let pattern = "([a-z0-9])([A-Z])"
return rawValue.replacingOccurrences(of: pattern, with: "$1_$2", options: .regularExpression).lowercased()
}
}