Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
Tags
- UICollectionView
- 스위프트
- swiftUI
- UITextView
- 리펙토링
- swift documentation
- Protocol
- uitableview
- map
- Human interface guide
- Refactoring
- Observable
- uiscrollview
- 리펙터링
- 클린 코드
- clean architecture
- Clean Code
- rxswift
- 애니메이션
- ribs
- tableView
- SWIFT
- RxCocoa
- 리팩토링
- HIG
- MVVM
- ios
- collectionview
- Xcode
- combine
Archives
- Today
- Total
김종권의 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:081. long press gesture와 애니메이션 - 드래그 구현 방법 (snapshotView, CGAffineTransform)
3. long press gesture와 애니메이션 - 드래그와 cornerRadius, shadow 효과 (CABasicAnimation)
4. long press gesture와 애니메이션 - UIStackView에 DragDrop 적용 (DragDropStackView 구현)
5. long press gesture와 애니메이션 - gesture 도중 화면 끝으로 가면 자동으로 스크롤되는 기능 구현 (#Horizontal Scroll, #수평 스크롤)
사용한 라이브러리
- 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()
}
}
}
(완료)
'UI 컴포넌트 (swift)' 카테고리의 다른 글
Comments