참고
위 링크를 보며 정리한 글 자세한 내용은 위 링크 참고 바람바람바람~
PM이 버튼을 클릭하면 떨리는 효과를 시작하는 뷰를 만들어달라고 한 상황을 가정해보자 암호에서 흔하게 사용하는 애니메이션을 생각하면 된다.
// FoodImageView.swift
import UIKit
class FoodImageView: UIImageView {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
class ViewController: UIViewController {
@IBOutlet weak var foodImageView: FoodImageView!
@IBAction func onShakeButtonTap(sender: AnyObject) {
foodImageView.shake()
}
}
뷰 컨트롤러에 잘 추가가 되었네요 !
하지만 PM이 다시 와서 뷰가 떨릴 때 버튼도 떨려야 한다고 지시합니다.
// ShakeableButton.swift
import UIKit
class ActionButton: UIButton {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
class ViewController: UIViewController {
@IBOutlet weak var foodImageView: FoodImageView!
@IBOutlet weak var actionButton: ActionButton!
@IBAction func onShakeButtonTap(sender: AnyObject) {
foodImageView.shake()
actionButton.shake()
}
}
서브 클래싱을 해서 버튼을 만들고 shake()
함수도 추가하였습니다. 이제 버튼과 이미지 뷰를 떨리게 하는 작업이 완료가 되었습니다.
하지만 지금 코드는 중복이 발생하고있습니다. 떨림 효과를 수정하려면 두 군데를 찾아가야 하니 깔끔하지 않습니다.
이런 문제를 파악하고 해결하는 방법은 여러가지가 있습니다. 바로 생각나는건 UIView
를 확장시키는 방법입니다.
// UIViewExtension.swift
import UIKit
extension UIView {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
하지만 이 방식은 가독성이 떨어집니다. 예를 들어, foodImageView, actionButton
에서 떨리는 기능을 파악할 수 없습니다. 클래스 어디에서도 이들이 떨릴거라는것을 알려주지 않습니다. 원래 기능이 아닌 무작위의 떨림 기능이 어딘가에 존재하므로 이 코드는 분명하지 않습니다.
떨림 기능을 넣은 후 또 희미해지는 기능을 추가하라는 요청이 온다면, 이 코드 아래도 또 변종 기능들이 줄줄이 추가될 확률이 높습니다. 이러한 상황을 해결하는 방법으로 프로토콜이 있습니다.
Shakeable
프로토콜을 만듭니다.
// Shakeable.swift
import UIKit
protocol Shakeable { }
extension Shakeable where Self: UIView {
func shake() {
// implementation code
}
}
// 프로토콜을 채택해서 떨림 기능이라는 의도를 알 수 있음
class FoodImageView: UIImageView, Shakeable { }
class ActionButton: UIButton, Shakeable { }
프로토콜 익스텐션으로 클래스가 이를 구현하도록 강제합니다. 이 경우 떨림 기능을 밖으로 꺼내고, 카테고리를 통해 이 프로토콜을 사용하는 것은 UIView
만임을 명시할 수 있습니다.
이제 뷰에 떨림기능과 희미해지는 기능이 필요하다고 하면 Dimmable
이라는 프로토콜을 만들어서 채택시키면 됩니다.
보통 셀을 등록할때 문자열로 받는 경우가 대부분일것이라고 생각한다. 그러한 부분도 프로토콜로 편하게 해결이 가능함.
import Foundation
extension NSObject {
static var identifier: String {
String(describing: self)
}
}
위 코드를 작성하면, 셀을 등록할때 cell.identifier
만 작성하면 문자열을 받을 수 있으므로, 가독성이 더 좋아진다.
다음으로 셀을 재사용하는 함수의 사용도 개선이 가능하다
// 컬렉션뷰 기준 테이블 뷰도 가틈
extension UICollectionView {
func dequeueReusableCell2<T: UICollectionViewCell>(forIndexPath indexPath: IndexPath) -> T {
guard let cell = dequeueReusableCell(withReuseIdentifier: T.identifier, for: indexPath) as? T else {
fatalError("Could not dequeue cell with identifier: \(T.identifier)")
}
return cell
}
}
// 아래와같이 사용하던 부분이,
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FoodTableViewCell", forIndexPath: indexPath)
as? FoodTableViewCell
else {
fatalError("Could not dequeue cell with identifier: FoodTableViewCell")
}
// 요런식으로다가 사용이 가능하다.
let cell = collectionView.dequeueReusableCell2(forIndexPath: indexPath) as FoodTableViewCell
이의 장점은, UIKit
로부터 오는 코드는 개발자에게 통제권이 없는데 프로토콜을 사용하여 조금 개선이 가능한 부분이다.
네트워킹을 위해서는 주로 API호출을 해야 합니다. 아래 코드는 일반적으로 API를 호출하는 패턴입니다. 하지만 전체 뷰 컨트롤러가 음식 배열의 로딩에 의존적입니다. 데이터가 없거나 잘못된 경우 실패합니다. 뷰 컨트롤러가 데이터로 해야하는 일을 제대로 하는지 확인하기 위한 최선의 방법은 테스트입니다.
// FoodLaLaViewController
struct FoodService {
func get(completionHandler: Result<[Food]> -> Void) {
// make asynchronous API call
// and return appropriate result
}
}
var dataSource = [Food]() {
didSet {
tableView.reloadData()
}
}
override func viewDidLoad() {
super.viewDidLoad()
getFood()
}
private func getFood() {
FoodService().getFood() { [weak self] result in
switch result {
case .Success(let food):
self?.dataSource = food
case .Failure(let error):
self?.showError(error)
}
}
}
뷰 컨트롤러를 테스트 하는것은 고통스럽다네요 ㅌㅋ 위 예제의 경우 서비스에 비동기 API 호출, 컴플리션 블럭과 Result
까지 있으니 복잡합니다
따라서 위 코드에 있는 FoodService
를 더 컨트롤할 필요가 있습니다. 음식 배열인지 에러인지 선택해서 넣어야 합니다. 하지만 getFood()
가 FoodService()
를 초기화할 때 결과를 선택해서 넣을 방법이 없습니다. 따라서 첫 테스트는 의존성을 주입하는 것입니다.
func getFood(fromService service: FoodService) {
service.getFood() { [weak self] result in
// handle result
}
}
// 그러면 아래에서 주입이 가능하다 !
// FoodLaLaViewControllerTests
func testFetchFood() {
viewController.getFood(fromService: FoodService())
// now what?
}
이제 우리에게 통제권이 늘어났습니다. 하지만 아직 FoodService
에 대한 전체 통제권이 없습니다. 이는 어떻게 얻을까요 ?
현재 FoodService
는 값 타입이라 서브클래싱을 할 수 없습니다. 따라서 프로토콜을 사용해야 합니다. FoodService
내부에는 get 함수와, 결과를 주는 completionHandler
가 있습니다. 앱 내의 다른 서비스 get이 필요한 다른 API호출도 비슷할 겁니다. 결과를 포함하는 컴플리션 핸들러가 있고 이를 파싱하겠죠
그래서 더 제네릭하게 바꿔봅니다 !
protocol Gettable {
associatedtype T
func get(completionHandler: Result<T> -> Void)
}
이런식으로 말이죠, 연관 타입을 사용해서 음식을 넣는다면 음식을, 후식을 넣는다면 후식 서비스가 됩니다.
struct FoodService: Gettable {
func get(completionHandler: Result<[Food]> -> Void) {
// make asynchronous API call
// and return appropriate result
}
}
유일한 변화는 Gettable
프로토콜을 채택했다는 점 말고는 없습니다. 뷰 컨트롤러는 예전과 거의 동일합니다.
override func viewDidLoad() {
super.viewDidLoad()
getFood(fromService: FoodService())
}
// Gettable을 따르는 서비스이고, 서비스의 연관타입이 [Food]인지 체크 !
func getFood<S: Gettable where S.T == [Food]>(fromService service: S) {
service.get() { [weak self] result in
switch result {
case .Success(let food):
self?.dataSource = food
case .Failure(let error):
self?.showError(error)
}
}
}
여기서 해줄 부분은, 다른 이상한 서비스(Gettable
을 따르는)를 부르지 않도록 연관 타입이 음식 배열([Food]
)임을 특정해 주는 것뿐입니다.
이를 참고하여, Fake_FoodService
를 생성할 수도 있습니다.
// FoodLaLaViewControllerTests
class Fake_FoodService: Gettable {
var getWasCalled = false
func get(completionHandler: Result<[Food]> -> Void) {
getWasCalled = true
completionHandler(Result.Success(food))
}
}
// 함수가 잘 불려서 성공을 확인하거나,
// 실패를 주입해서 컨트롤러가 결과 입력에 따라 의도한대로 동작한지 확인합니다.
func testFetchFood() {
let fakeFoodService = Fake_FoodService()
viewController.getFood(fromService: fakeFoodService)
XCTAssertTrue(fakeFoodService.getWasCalled)
XCTAssertEqual(viewController.dataSource.count, food.count)
XCTAssertEqual(viewController.dataSource, food)
}
우리가 통제 가능한 fakeFoodService
를 주입해 get
함수가 호출되고 FoodService
로 부터 주입된 데이터 소스가 뷰컨에 할당된 것과 동일한지 테스트 가능합니다.
이 글을 통해, 프로토콜을 활용해 좀 더 제네릭하고, 확장성이 좋은 프로그래밍 방식을 조금 배웠다.. 앞으로 프로젝트에 많이 활용해야 하지 싶으,, 더 알아봐야하긴 하겠지마는 ~