본문 바로가기
이모저모/UIKit

UIButton State 애니메이션용 모듈 만들고 적용하기

by ARpple 2023. 11. 25.

UIButton.Configuration을 기반으로 만든 모듈이다.
iOS 15 이상 적용 가능하다는 의미

 

Meet the UIKit button system - WWDC21 - Videos - Apple Developer

Every app uses Buttons. With iOS 15, you can adopt updated styles to create gorgeous buttons that fit effortlessly into your interface...

developer.apple.com

UIButton.Configuration 참고용 WWDC 영상

기존에 버튼 애니메이션을 구성하는 코드

final class CustomButton:UIButton{ // 크기 변화만 발생하는 코드
init(){
...
    let handler: UIButton.ConfigurationUpdateHandler = {[weak self] button in // 1
            guard let self else {return}
            switch button.state {
            case .selected,.highlighted,[.selected,.highlighted]:
                UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.5,initialSpringVelocity: 0.5,options: .curveEaseIn) {
                            button.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
                        }completion: { _ in
                            UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.5,initialSpringVelocity: 0.5,options: .curveEaseIn){
                                button.transform = CGAffineTransform(scaleX: 1, y: 1)
                            }
                        }
            default: break
            }
        }
        self.configurationUpdateHandler = handler
...
    }
}
⚠️ 각각의 UIButton.ConfigurationUpdateHandler에 넣고 싶은 애니메이션 코드를 넣어주어야 한다.
애니메이션 로직과 ConfigurationUpdateHandler를 분리해서 애니메이션 로직을 추가하면 버튼에서 알아서 동작하도록 만들자

 

모듈을 적용한 코드

fileprivate final class OnboardingBtn:UIButton{
    init(text:String,textColor:UIColor,bgColor:UIColor){
        ...
        var animSnapshot = self.animationSnapshot
                // 여기에서 버튼에 동작 시키고 싶은 애니메이션을 추가한다.
        let animSnapshot = self.animationSnapshot
                            .bgEffect(effectColor: .blue).scaleEffect(ratio: 0.95)
        do{
                // 애니메이션을 적용한다.
            try self.apply(animationSnapshot: updatedSnapshot)
        }catch{
            print(error)
        }
        ...
    }
}

 


내부 구현

  1. UIButton의 Extension 메서드
    1. var animationSnapshot:ButtonAnimations
    2. func apply(animationSnapshot: ButtonAnimations) throws
      ⇒ 애니메이션 스냅샷이라는 구조체를 가져오고 이를 UIButton 인스턴스에 적용하는데 사용됨
  2. 모듈 ⭐ ButtonAnimations 구조체
    ⇒ 버튼 애니메이션 로직을 추가하고 UIButton.ConfigurationUpdateHandler를 만드는데 사용됨
  3. UIButton apply시 오류 케이스 열거형

ButtonAnimations 구조체

  1. 저장 프로퍼티
    1. 애니메이션 로직을 담는 배열: [()→()] 
    2. 버튼 State에 알맞는 애니메이션 로직을 담는 딕셔너리: [ActionType:[()->()]]
    3. 적용할 버튼의 인스턴스: UIButton
  2. 계산 프로퍼티
    1. UIButton.ConfigurationUpdateHandler 반환
    2. ⇒ 각각의 버튼 상태에 맞게 애니메이션 로직 클로져 출력
  3. 메서드
    1. 저장 프로퍼티에 갖고 있는 버튼과 일치하는지 확인하는 메서드: checkSame(btn: UIButton)->Bool
    2. 애니메이션 메서드 → 필요한 애니메이션이 있을 때 마다 추가하자
  4. 내부 열거형
    1. 커스텀하게 조절할 버튼 State를 갖는 Enum: enum ActionType:CaseIterable 

전체 코드

import Foundation
import UIKit
extension UIButton{
    var animationSnapshot:ButtonAnimations{ ButtonAnimations(btn: self) }
    func apply(animationSnapshot: ButtonAnimations) throws {
        if animationSnapshot.checkSame(btn: self){
            self.configurationUpdateHandler = animationSnapshot.myHandler
        }else{
            throw SnapshotError.buttonNotSame
        }
    }
}
struct ButtonAnimations{
    private var anims:[()->()] = []
    private var actionByState:[ActionType:[()->()]]
    enum ActionType:CaseIterable{
        case selected
        case highlighed
        case defaults
    }
    private weak var btn : UIButton!
    init(btn: UIButton) {
        self.btn = btn
        self.actionByState =  ActionType.allCases.reduce(into: [:], { partialResult, type in
            partialResult[type] = []
        })
    }
    private init(btn: UIButton,anims:[()->()],actionByState:[ActionType:[()->()]]){
        self.btn = btn
        self.anims = anims
        self.actionByState = actionByState
    }
    @discardableResult
    func scaleEffect(ratio:CGFloat = 0.9)->Self{
        var anim = self.anims
        anim.append{
            UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.4,initialSpringVelocity: 1,options: .preferredFramesPerSecond60){
                btn.transform = CGAffineTransform(scaleX: ratio, y: ratio)
            }completion: { _ in
                UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.5,initialSpringVelocity: 0.5,options: .curveEaseIn){
                    btn.transform = CGAffineTransform(scaleX: 1, y: 1)
                }
            }
        }
        return ButtonAnimations(btn: self.btn, anims: anim,actionByState: self.actionByState)
    }
    @discardableResult
    func bgEffect(effectColor:UIColor)->Self{
        var actionByStates = self.actionByState
        let prevColor = self.btn.configuration?.background.backgroundColor
        let effectAction = {
            self.btn.configuration?.baseBackgroundColor = effectColor
            self.btn.configuration?.background.backgroundColor = effectColor
        }
        actionByStates[.selected]?.append(effectAction)
        actionByStates[.highlighed]?.append(effectAction)
        actionByStates[.defaults]?.append {
            self.btn.configuration?.baseBackgroundColor = prevColor
            self.btn.configuration?.background.backgroundColor = prevColor
        }
        return ButtonAnimations(btn: self.btn, anims: anims,actionByState: actionByStates)
    }
    func checkSame(btn: UIButton)->Bool{
        return self.btn == btn
    }
}
extension ButtonAnimations{
    var myHandler: UIButton.ConfigurationUpdateHandler{
        return {button in // 1
            switch button.state {
            case .selected,.highlighted,[.selected,.highlighted]:
                anims.forEach { anim in anim() }
                actionByState[.selected]?.forEach({$0() })
// 이렇게 케이스를 전부 나누는게 좋겠지만,
//               일단 위에처럼 selected, highlighted 상태를 모두 동일한 로직을 처리하게 쓰고 있다.
//            case .selected:
//                actionByState[.selected]?.forEach({$0() })
//            case .highlighted:
//                actionByState[.highlighed]?.forEach{$0()}
            default:
                actionByState[.defaults]?.forEach({ $0() })
            }
        }
    }
}
enum SnapshotError: Error{
    case buttonNotSame
}
⛔  같은 애니메이션 메서드를 중복해서 체이닝하는 경우 오류 처리를 해야한다.
UIViewPropertyAnimator라는 새로운 요소로 UI의 Animation을 적용하면 더 좋게 개선할 수 있을것 같기도 하다.
(아직 자세히 모름...)

댓글