관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 4. long press gesture와 애니메이션 - UIStackView에 DragDrop 적용 (DragDropStackView 구현) 본문

UI 컴포넌트 (swift)

[iOS - swift] 4. long press gesture와 애니메이션 - UIStackView에 DragDrop 적용 (DragDropStackView 구현)

jake-kim 2023. 7. 21. 22:08

1. long press gesture와 애니메이션 - 드래그 구현 방법 (snapshotView, CGAffineTransform)

2. long press gesture와 애니메이션 - 드래그할때 다른  줄어들고, 해당  크게하기 (UIView.animate, CGAffineTransform, concatenating)

3. long press gesture와 애니메이션 - 드래그와 cornerRadius, shadow 효과 (CABasicAnimation)

4. long press gesture와 애니메이션 - UIStackView에 DragDrop 적용 (DragDropStackView 구현)

5. long press gesture와 애니메이션 - gesture 도중 화면 끝으로 가면 자동으로 스크롤되는 기능 구현 (#Horizontal Scroll, #수평 스크롤)

UIStackView에 drag & drop 적용된 모습

사용한 라이브러리

  • Rx 관련 라이브러리
    • RxSwift
    • RxCocoa
    • RxGesture
  • 코드 베이스 구현 시 사용하면 편한 라이브러리
    • SnapKit
    • Then
pod 'RxSwift'
pod 'RxCocoa'
pod 'RxGesture'
pod 'SnapKit'
pod 'Then'

구현 아이디어

  • long press gresture 등록
    • began, changed, ended 각 상태에 따른 애니메이션 처리 추가
  • began 상태
    • 이동할 뷰를 snapshot 하고, CGAffineTransform을 통해 이동할 뷰의 크기는 크게, 나머지 뷰들은 작게 변환
  • changed 상태
    • CGAffineTransform을 통하여 뷰를 이동
    • 이동중인 뷰의 frame을 사용하면 현재 이동된 좌표를 알 수 있으므로 midY와 최초 드래그 시작할 때 값(lastY)을 비교하여 move
    • move가 발생하면 lastY를 업데이트
  • ended 상태
    • CGAffineTransform을 통해 애니메이션 및 affine 변환이 적용된 뷰들을 다시 되돌리기
    • affine 변환은 (.identify)로 쉽게 롤백이 가능

구현

  • 드래그 앤 드롭으로 아이템 변환 이벤트를 받을 델리게이트 프로토콜 정의
protocol DragDropStackViewDelegate {
    func didBeginDrag()
    func dargging(inUpDirection up: Bool, maxY: CGFloat, minY: CGFloat)
    func didEndDrop()
}
  • Draggable 프로토콜을 만들어서 이 프로토콜을 따르는 UIStackView는 드래그 기능을 사용할 수 있도록 구현
    • 해당 프로토콜에 사용될때 설정값들을 담은 DragDropConfig도 같이 선언
// DragDropable.swift

import UIKit
import RxSwift
import RxCocoa
import RxGesture

struct DragDropConfig {
    let clipsToBoundsWhileDragDrop: Bool
    let dragEffectCornerRadius: Double
    let dargViewScale: Double
    let otherViewsScale: Double
    let temporaryViewAlpha: Double
    let dragBeganEffectOffsetY: Double
    let longPressMinimumPressDuration: Double
    
    init(
        clipsToBoundsWhileDragDrop: Bool = false,
        dragEffectCornerRadius: Double = 8.0,
        dargViewScale: Double = 1.2,
        otherViewsScale: Double = 0.9,
        temporaryViewAlpha: Double = 0.85,
        dragBeganEffectOffsetY: Double = 4.0,
        longPressMinimumPressDuration: Double = 0.2
    ) {
        self.clipsToBoundsWhileDragDrop = clipsToBoundsWhileDragDrop
        self.dragEffectCornerRadius = dragEffectCornerRadius
        self.dargViewScale = dargViewScale
        self.otherViewsScale = otherViewsScale
        self.temporaryViewAlpha = temporaryViewAlpha
        self.dragBeganEffectOffsetY = dragBeganEffectOffsetY
        self.longPressMinimumPressDuration = longPressMinimumPressDuration
    }
}

protocol DragDropable: AnyObject {
    var dargDropDelegate: DragDropStackViewDelegate? { get }
    var config: DragDropConfig { get }
    var gestures: [UILongPressGestureRecognizer] { get set }
    var disposeBag: DisposeBag { get }
    
    /// must call each views in stackView's addArrangedSubview
    func addLongPressGestureForDragDrop(arrangedSubview: UIView)
}
  • extension으로 addLongPressGestureForDragDrop() 메소드 기능 확장
extension DragDropable where Self: UIStackView {
  // TODO
}
  • DragDropable 내부에서 사용되는 stored 프로퍼티가 있으므로, AssociatedObject를 사용
    • extension에 stored property 선언 방법은 이전 포스팅 글 참고
private struct AssociatedKeys {
    static var isStatusDragging = "isStatusDragging"
    static var finalDragDropFrame = "finalDragDropFrame"
    static var originalPosition = "originalPosition"
    static var pointForDragDrop = "pointForDragDrop"
    static var actualView = "actualView"
    static var snapshotView = "snapshotView"
}

extension DragDropable {
    var isStatusDragging: Bool {
        get { (objc_getAssociatedObject(self, &AssociatedKeys.isStatusDragging) as? Bool) ?? false }
        set { objc_setAssociatedObject(self, &AssociatedKeys.isStatusDragging, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
    }
    private var finalDragDropFrame: CGRect {
        get { (objc_getAssociatedObject(self, &AssociatedKeys.finalDragDropFrame) as? CGRect) ?? .zero }
        set { objc_setAssociatedObject(self, &AssociatedKeys.finalDragDropFrame, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
    }
    private var originalPosition: CGPoint {
        get { (objc_getAssociatedObject(self, &AssociatedKeys.originalPosition) as? CGPoint) ?? .zero }
        set { objc_setAssociatedObject(self, &AssociatedKeys.originalPosition, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
    }
    private var pointForDragDrop: CGPoint {
        get { (objc_getAssociatedObject(self, &AssociatedKeys.pointForDragDrop) as? CGPoint) ?? .zero }
        set { objc_setAssociatedObject(self, &AssociatedKeys.pointForDragDrop, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
    }
    private var actualView: UIView? {
        get { (objc_getAssociatedObject(self, &AssociatedKeys.actualView) as? UIView) }
        set { objc_setAssociatedObject(self, &AssociatedKeys.actualView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
    }
    private var snapshotView: UIView? {
        get { (objc_getAssociatedObject(self, &AssociatedKeys.snapshotView) as? UIView) }
        set { objc_setAssociatedObject(self, &AssociatedKeys.snapshotView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
    }
}
  • addLongPressGestureForDragDrop에서 제스쳐를 등록하고 각 동작 처리
extension DragDropable where Self: UIStackView {
    func addLongPressGestureForDragDrop(arrangedSubview: UIView) {
        arrangedSubview.rx.longPressGesture(configuration: { [weak self] gesture, delegate in
            gesture.minimumPressDuration = self?.config.longPressMinimumPressDuration ?? 0
            gesture.isEnabled = true
            arrangedSubview.addGestureRecognizer(gesture)
            self?.gestures.append(gesture)
        })
        .subscribe { [weak self] gesture in
            self?.handleLongPress(gesture)
        }
        .disposed(by: disposeBag)
    }
    
    func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        switch gesture.state {
        case .began:
            handleBegan(gesture: gesture)
        case .changed:
            handleChanged(gesture: gesture)
        default:
            // ended, cancelled, failed
            handleEnded(gesture: gesture)
        }
    }
    ...
  • handleBegan 
    • UIView.animate를 통해 duration과 spring 속성을 자연스럽게 지정한 후 affine 변환을 통해 애니메이션 효과 부여
    • 각 전역변수에 필요한 값을 대입
    • 핵심1) originalPosition 프로퍼티: changed에서 뷰가 얼마만큼 이동했는지 offset을 알 수 있어서, affine 변환을 통해 뷰 이동이 가능
    • 핵심2) finalDragDropFrame 프로퍼티: 드래그하는 뷰의 위치 (최초, 이 다음에 변경된 지점)를 기록하며 ended 상태에서 드래깅하고 있는 뷰의 위치를 잡아주기 위함
func handleBegan(gesture: UILongPressGestureRecognizer) {
    guard !isStatusDragging else { return } // 동시에 여러개가 터치되는 현상을 막기 위함
    isStatusDragging = true
    dargDropDelegate?.didBeginDrag()
    if let gestureView = gesture.view {
        actualView = gestureView
    }
    originalPosition = gesture.location(in: self)
    originalPosition.y -= config.dragBeganEffectOffsetY
    pointForDragDrop = originalPosition
    animateBeganDrag()
}

func animateBeganDrag() {
    clipsToBounds = config.clipsToBoundsWhileDragDrop
    guard let actualView else { return }
    
    snapshotView = actualView.snapshotView(afterScreenUpdates: true)
    snapshotView?.frame = actualView.frame
    finalDragDropFrame = actualView.frame
    if let snapshotView {
        addSubview(snapshotView)
    }
    
    actualView.alpha = 0
    
    UIView.animate(
        withDuration: 0.4,
        delay: 0,
        usingSpringWithDamping: 0.8,
        initialSpringVelocity: 0,
        options: [.allowUserInteraction, .beginFromCurrentState],
        animations: { self.animateBeganDragEffect() },
        completion: nil
    )
}

func animateBeganDragEffect() {
    let scale = CGAffineTransform(scaleX: config.dargViewScale, y: config.dargViewScale)
    let translation = CGAffineTransform(translationX: 0, y: config.dragBeganEffectOffsetY)
    snapshotView?.transform = scale.concatenating(translation)
    snapshotView?.alpha = config.snapshotViewAlpha
    
    arrangedSubviews
        .filter { $0 != actualView }
        .forEach { subview in
            subview.transform = CGAffineTransform(scaleX: config.otherViewsScale, y: config.otherViewsScale)
        }
}
  • handleChanged
    • CGAffineTransform을 통하여 뷰를 이동
    • 이동중인 뷰의 frame을 사용하면 현재 이동된 좌표를 알 수 있으므로 midY와 최초 드래그 시작할 때 값(lastY)을 비교하여 move
func handleChanged(gesture: UILongPressGestureRecognizer) {
    let newLocation = gesture.location(in: self)
    let xOffset = newLocation.x - originalPosition.x
    let yOffset = newLocation.y - originalPosition.y
    let translation = CGAffineTransform(translationX: xOffset, y: yOffset)
    
    guard let snapshotView else { return }
    let scale = CGAffineTransform(scaleX: config.dargViewScale, y: config.dargViewScale)
    snapshotView.transform = scale.concatenating(translation)
    
    let maxY = snapshotView.frame.maxY
    let midY = snapshotView.frame.midY
    let minY = snapshotView.frame.minY
    let index = arrangedSubviews
        .firstIndex(where: { $0 == actualView }) ?? 0
    
    if midY > pointForDragDrop.y {
        handleChangedWhenDraggingDown(index: index, maxY: maxY, midY: midY, minY: minY)
    } else {
        handleChangedWhenDraggingUp(index: index, maxY: maxY, midY: midY, minY: minY)
    }
}

func handleChangedWhenDraggingDown(index: Int, maxY: Double, midY: Double, minY: Double) {
    dargDropDelegate?.dargging(inUpDirection: false, maxY: maxY, minY: minY)
    guard
        let nextView = arrangedSubviews[safe: index + 1],
        let actualView,
        midY > nextView.frame.midY
    else { return }
    
    UIView.animate(
        withDuration: 0.2,
        animations: {
            self.insertArrangedSubview(nextView, at: index)
            self.insertArrangedSubview(actualView, at: index + 1)
        }
    )
    finalDragDropFrame = actualView.frame
    pointForDragDrop.y = actualView.frame.midY
}

func handleChangedWhenDraggingUp(index: Int, maxY: Double, midY: Double, minY: Double) {
    dargDropDelegate?.dargging(inUpDirection: true, maxY: maxY, minY: minY)
    guard
        let previousView = arrangedSubviews[safe: index - 1],
        let actualView,
        midY < previousView.frame.midY
    else { return }
    
    UIView.animate(
        withDuration: 0.2,
        animations: {
            self.insertArrangedSubview(previousView, at: index)
            self.insertArrangedSubview(actualView, at: index - 1)
        }
    )
    finalDragDropFrame = actualView.frame
    pointForDragDrop.y = actualView.frame.midY
}
  • handleEnded
    • CGAffineTransform을 통해 애니메이션 및 affine 변환이 적용된 뷰들을 다시 되돌리기
func handleEnded(gesture: UILongPressGestureRecognizer) {
    animateDrop()
    isStatusDragging = false
    dargDropDelegate?.didEndDrop()
}

func animateDrop() {
    UIView.animate(
        withDuration: 0.4,
        delay: 0,
        usingSpringWithDamping: 0.8,
        initialSpringVelocity: 0,
        options: [.allowUserInteraction, .beginFromCurrentState],
        animations: { self.animateDropEffect() },
        completion: { _ in
            self.snapshotView?.removeFromSuperview()
            self.actualView?.alpha = 1
            self.clipsToBounds = !self.config.clipsToBoundsWhileDragDrop
        }
    )
}

func animateDropEffect() {
    snapshotView?.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
    snapshotView?.frame = finalDragDropFrame
    snapshotView?.alpha = 1.0
    
    arrangedSubviews
        .forEach { subview in
            UIView.animate(
                withDuration: 0.3) {
                    subview.transform = .identity
                } completion: { _ in
                    subview.layer.removeAllAnimations()
                }
        }
}

(완료)

UIStackView에 drag & drop 적용된 모습

* 전체 코드: https://github.com/JK0369/ExDragDropStackView

Comments