Dribbble의 Valerya Nasikan UX 디자이너의 프로젝트를 보고 앱으로 구현한 내용에 대한 기술입니다.

https://dribbble.com/shots/3489204-Flower-App

UICollectionView의 스크롤 효과를 모두 활용할 수 있고 앱으로 활용하기 좋은 UX인 듯 하여, iOS 앱으로 바로 구현했습니다.

메인화면

raywenderlichCustom Collection View Layout강좌를 토대로 구현했습니다.

기존 강좌에서는 UICollectionView를 vertical로 스크롤 하는 방식에 대해서 설명하였는데, 이를 horizontal로 변경하여 코드를 작성했습니다.

아래는 스크롤 에니메이션 관련 코드 입니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
class FlowerListFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
var newAttributes = [UICollectionViewLayoutAttributes]()
for itemAttributes in attributes! {
let newItemAttributes = itemAttributes.copy() as! UICollectionViewLayoutAttributes
changeLayoutAttributes(newItemAttributes)
newAttributes.append(newItemAttributes)
}
return newAttributes
}
func changeLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) {
let collectionCenter = collectionView!.frame.size.width / 2
let offset = collectionView!.contentOffset.x
let normalizedCenter = attributes.center.x - offset
let maxDistance = self.itemSize.width + self.minimumLineSpacing
let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
let ratio = (maxDistance - distance)/maxDistance
let alpha = ratio * (1 - self.standardItemAlpha) + self.standardItemAlpha
let scale = ratio * (1 - self.standardItemScale) + self.standardItemScale
attributes.alpha = alpha
attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
attributes.zIndex = Int(alpha * 10)
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let layoutAttributes = self.layoutAttributesForElements(in: collectionView!.bounds)
let center = collectionView!.bounds.size.width / 2
let proposedContentOffsetCenterOrigin = proposedContentOffset.x + center
let closest = layoutAttributes!.sorted { abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
let targetContentOffset = CGPoint(x: floor(closest.center.x - center), y: proposedContentOffset.y)
return targetContentOffset
}
}

Push Animation

NavigationControllerDelegate를 subclass하여 구현하였습니다.
해당 앱은 NavigationController를 기반에 둔 화면 전환이기 때문에, 각각의 화면전환 시 에니메이션 지정이 필요 한 경우 NavigationControllerDelegate를 subClassing하여 Custom한 에니메이션을 지정할 수 있습니다.

Subclassing하기 위한 파일을 생성합니다.

Storyboard로 이동 후 NavigationController에서 Delegate를 설정합니다.

이렇게 Delegate를 지정 후 파일로 이동, 각각 화면 이동 시 지정할 Animation을 지정합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class NavigationControllerDelegate : NSObject, UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation,
from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if operation == .push {
if fromVC is FlowerInfosController {
return FlowerCollectionViewDetailPushAnimator()
} else {
return FlowerCollectionViewPushAnimator()
}
} else {
if fromVC is FlowerDetailController {
return FlowerCollectionViewDetailPopAnimator()
} else {
return FlowerCollectionViewPopAnimator()
}
}
}
}

메인화면 > 리스트화면 에니메이션

메인화면에서 리스트화면 진입 시 에니메이션은
현재화면의 scale을 키우면서 이동할 화면이 서서히 보이는 효과로 구현돼 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let fromViewController = transitionContext.viewController(forKey: .from)!
let toViewController = transitionContext.viewController(forKey: .to)!
transitionContext.containerView.insertSubview(toViewController.view, aboveSubview: fromViewController.view)
toViewController.view.alpha = 0.0
UIView.animate(withDuration: self.transitionDuration(using: transitionContext), animations: {
fromViewController.view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
toViewController.view.alpha = 1.0
}) { (finished) in
fromViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
transitionContext.completeTransition(true)
}
}

리스트화면 > 상세화면 에니메이션

리스트화면에서 상세화면 진입 시 에니메이션은 세가지 효과가 동시에 표현하도록 돼있습니다.

  • 리스트화면에 있는 이미지가 상세화면 상단에 위치
  • 꽃에 대한 설명은 아래에서 위로 올라오는 에니메이션
  • 리스트화면의 배경이 서서히 커지면서 상세화면의 색으로 지정

각각 animation을 지정하기 위해 각각 snapshot을 만들고 그 snapshot에 animation을 지정합니다. 그렇게 하면 진입할 화면에 대한 frame을 깨지않으면서 자유로운 에니메이션을 지정할 수 있기 때문에 이런방식을 사용했습니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let fromViewController = transitionContext.viewController(forKey: .from)!
let toViewController = transitionContext.viewController(forKey: .to)!
let sourceVC = fromViewController as! FlowerDetailController
let destinationVC = toViewController as! FlowerInfosController
transitionContext.containerView.insertSubview(toViewController.view, aboveSubview: fromViewController.view)
// to
guard let selectedItem = destinationVC.selectedIndexPath, let infoCell = destinationVC.collectionView?.cellForItem(at: selectedItem) as? FlowerInfosCell else {
transitionContext.completeTransition(true)
return
}
// from
// background
let snapBackgroundView = UIView(frame: infoCell.contentView.frame)
snapBackgroundView.backgroundColor = infoCell.contentView.backgroundColor
snapBackgroundView.frame.origin = infoCell.contentView.convert(.zero, to: nil)
transitionContext.containerView.addSubview(snapBackgroundView)
let defaultScaleX: CGFloat = sourceVC.imageView.width / infoCell.imageView.width
let defaultScaleY: CGFloat = sourceVC.imageView.height / infoCell.imageView.height
snapBackgroundView.transform = CGAffineTransform(scaleX: defaultScaleX * 3, y: defaultScaleY * 3)
// imageView
guard let snapImageView = sourceVC.imageView.snapshot else {
transitionContext.completeTransition(true)
return
}
snapImageView.frame.origin = sourceVC.imageView.convert(.zero, to: nil)
transitionContext.containerView.addSubview(snapImageView)
// description
guard let snapDescription = sourceVC.descriptionView.snapshot else {
transitionContext.completeTransition(true)
return
}
snapDescription.frame.origin = sourceVC.descriptionView.convert(.zero, to: nil)
transitionContext.containerView.addSubview(snapDescription)
// scale
let animationScaleX: CGFloat = infoCell.imageView.frame.size.width / sourceVC.imageView.frame.size.width
let animationScaleY: CGFloat = infoCell.imageView.frame.size.height / sourceVC.imageView.frame.size.height
UIView.animate(withDuration: self.transitionDuration(using: transitionContext), animations: {
snapBackgroundView.transform = CGAffineTransform.identity
snapImageView.transform = CGAffineTransform(scaleX: animationScaleX, y: animationScaleY)
let cellImageOrigin: CGPoint = infoCell.imageView.convert(.zero, to: nil)
snapImageView.frame = CGRect(x: cellImageOrigin.x, y: cellImageOrigin.y, width: infoCell.imageView.frame.size.width, height: infoCell.imageView.frame.size.height)
snapDescription.frame.origin.y += sourceVC.view.height
}) { (finished) in
snapImageView.removeFromSuperview()
snapBackgroundView.removeFromSuperview()
snapDescription.removeFromSuperview()
transitionContext.completeTransition(true)
}
}

Source

위 예제에 대한 소스는 github에서 다운 받으실 수 있습니다.