-
IOS 에서 머터리얼 디자인의 물결 이펙트 버튼 만들기iOS 2019. 12. 26. 01:35반응형
안드로이드의 기본 컴포넌트 버튼 Button의 경우, 별다른 설정을 하지 않아도 클릭할 때 물결 모양이 나타납니다. 반면에, iOS 버튼 UIButton의 경우, 클릭할 시에 물결 설정이 없습니다.
https://developer.apple.com/documentation/uikit/uicontrol/1618231-ishighlighted
물론, UIControl 의 isHighlighted 을 활용해서, 하이라이트된 경우 백그라운드의 색상에 포인트를 준다든지, 진동을 준다든지 하면 어느 정도 포인트를 줄 수 있습니다만, 약간 재미가 없던 것이 사실입니다.
괜찮은 iOS 리플 이펙트의 소스를 찾아보려고 했습니다만, 잘 찾을 수 없었기에 그냥 한번 짜보자 생각해 보게 되었습니다.
리서치 중 가장 참고가 된 것은 민소네님이 올린 리플 터치 이펙트 소스 코드입니다.
생각보다 많은 것이 정리되어 있었고, 여기서 충분한 아이데이션을 얻을 수 있었던 것 같습니다.
http://minsone.github.io/mac/ios/ripple-touch-effect-like-google-material-design-on-ios
물결 효과를 주는 레이어를 따로 정의하고, 클릭 시에 CABasicAnimation으로 알파값, 트랜스폼값, 백그라운드 컬러 값등을 조절해서 만드는 방식이었습니다.
여기서 아이데이션을 얻어 처음에 버튼을 만들어 보았지만, 제가 원하는 메터리얼 디자인의 물결을 정확히 모방하기에는 역부족이라는 생각이 들었습니다.
https://material.io/components/buttons/
결국 구글에서 제공하는 머터리얼 iOS 버튼을 임포트해서, 필요한 부분만 빼어 Swift 로 전환해 만들 생각을 했습니다.
코드 줄은 많지 않습니다만... 악마는 디테일에 있었다라고 할까요, 제가 미적 감각이 아무래도 부족하다 보니까, 만족스러운 버튼 이펙트를 만들기 위해서 타이밍 등을 세부적으로 조정하는 게 가장 어려웠던 것 같습니다.
우선 레이어 하나를 오버라이드하여 물결 이펙트를 주도록 정의합니다.
func setPath() { let radius: CGFloat = sqrt( self.bounds.width * self.bounds.width + self.bounds.height * self.bounds.height ) let ovalRect = CGRect( x: self.bounds.midX - radius, y: self.bounds.midY - radius, width: radius * 2, height: radius * 2 ) let path = UIBezierPath.init(ovalIn: ovalRect) self.path = path.cgPath self.frame = self.bounds self.borderWidth = 0 }
이 때, 타원형 물결을 사실적으로 묘사하기 위해서 UIBezierPath 을 이용하는 것이 포인트입니다.
그리고 클릭할 시에, 물결이 퍼지는 애니메이션을 복합적인 CABasicAnimation 으로 정의합니다.
@objc func hitEffect(point: CGPoint) { self.setPath() self.isAnimating = true CATransaction.begin() CATransaction.setCompletionBlock { self.isAnimating = false if self.fadeOutIfComplete { self.fadeOutEffect() } } let positionAnimation: CAKeyframeAnimation = { let animation = CAKeyframeAnimation() let centerPath: UIBezierPath = { let path = UIBezierPath() let startPoint = point let endPoint = CGPoint(x: self.bounds.midX, y: self.bounds.midY) path.move(to: startPoint) path.addLine(to: endPoint) path.close() return path }() animation.keyPath = "position" animation.path = centerPath.cgPath animation.keyTimes = [0, 1] animation.values = [0, 1] animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1) return animation }() let scaleAnimation: CABasicAnimation = { let animation = CABasicAnimation(keyPath: "transform.scale") animation.fromValue = Self.kRippleStartingScale animation.toValue = 1 animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1) return animation }() let colorAnimation: CABasicAnimation = { let animation = CABasicAnimation(keyPath: "opacity") animation.fromValue = 0 animation.toValue = self.opacityValue animation.duration = CFTimeInterval(Self.kRippleFadeInDuration) return animation }() let group: CAAnimationGroup = { let group = CAAnimationGroup() group.duration = 0.5 group.animations = [positionAnimation, scaleAnimation, colorAnimation] group.duration = CFTimeInterval(Self.kRippleTouchDownDuration) return group }() self.add(group, forKey: "all") CATransaction.commit() }
상당히 복잡합니다. 클릭한 포인트에서 시작하여 물결이 중심으로 이동하는 애니메이션을 position 으로 정의했습니다.
물결이 점차 퍼지는 애니메이션은 transform.scale 으로 정의했습니다.
물결의 농도가 점차 진해지는 애니메이션을 opacity 로 정의했습니다.
이 애니메이션을 하나의 그룹으로 묶어서 물결이 퍼지는 것을 표현합니다.
@objc public func fadeOutEffect() { CATransaction.begin() CATransaction.setCompletionBlock { self.removeFromSuperlayer() } let colorAnimation = CABasicAnimation(keyPath: "opacity") colorAnimation.fromValue = self.opacityValue colorAnimation.toValue = 0 colorAnimation.duration = CFTimeInterval(Self.kRippleFadeOutDuration) self.add(colorAnimation, forKey: "fadeIn") CATransaction.commit() }
다음으로, 퍼진 물결이 자연스럽게 소멸되는 애니메이션을 따로 정의합니다.
이 애니메이션에서는, 퍼져서 충분히 물결이 진해졌던 것을 연해지는 것만 정의합니다.
이렇게 퍼지는 물결 애니메이션과 물결이 사라지는 애니메이션의 함수를 따로 분리한 이유가 있습니다.
사용자가 클릭을 하고 손가락을 바로 버튼에서 뗄 경우에는 두 애니메이션을 연달아 진행시켜야 하지만,
계속 누르고 있는 채라면 하이라이트된 상태를 유지해야 하기 때문입니다.
사용자가 클릭을 시작했을 때 hitEffect 만을 호출하고, 버튼에서 손을 뗐을 때 fadeOutEffect 을 실행하면 될 것입니다.
이렇게 생성한 물결 레이어를 버튼에 직접 붙일 경우, 물결 이펙트가 버튼을 넘어가게 되어 버립니다. 그래서 버튼 안에 뷰를 넣고, 뷰에 clipsToBounds 속성을 true 로 해 줄 필요가 있습니다.
이제 버튼 내부 뷰에 물결 레이어를 추가하면, 물결이 버튼을 삐져나오는 일이 없게 될 것입니다.
open class RippleView: UIView { public var rippleLayer: RippleLayer? public var rippleFillColor: UIColor? public override init(frame: CGRect) { super.init(frame: frame) self.clipsToBounds = true } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesBegan(touches, with: event) let rippleLayer = RippleLayer() rippleLayer.frame = self.frame rippleLayer.fillColor = self.rippleFillColor?.cgColor self.layer.addSublayer(rippleLayer) let point = touches.first?.location(in: self) ?? .zero rippleLayer.hitEffect(point: point) self.rippleLayer?.fadeOutIfComplete = true self.rippleLayer = rippleLayer } open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesEnded(touches, with: event) self.fadeOutRipple() } open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesMoved(touches, with: event) if let point = touches.first?.location(in: self) { if self.point(inside: point, with: event) { } else { self.fadeOutRipple() } } } private func fadeOutRipple() { guard let rippleLayer = self.rippleLayer else { return } if rippleLayer.isAnimating { rippleLayer.fadeOutIfComplete = true } else { rippleLayer.fadeOutEffect() } } open override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesCancelled(touches, with: event) } }
내부 뷰에서 애니메이션 처리를 진행합니다.
터치가 시작할 때 물결 레이어를 추가하고, 애니메이션을 시작합니다.
물결이 끝나거나, 손가락이 뷰 밖으로 이동했을 때 물결 레이어에 페이드 아웃 효과를 주고 물결 레이어를 제거합니다.
레이어를 동적으로 생성하는 이유는, 여러 번 연달아 클릭했을 때 물결 또한 연달아 생성되는 것이 자연스럽기 때문입니다. 동적으로 여러 레이어를 생성하면서, 연속된 물결 효과를 줄 수 있게 됩니다.
물결 효과 버튼을 라이브러리로 공개합니다
300여줄이나 되어서 은근히 로직이 많으므로, 초보자가 코드를 직접 구현하는 것은 어려울 것이라 생각했습니다.
그래서 간단히 구현한 코드를 코코아팟에 올려 두었습니다.
pod 'Rippleable'
로 설치한 후에,
import Rippleable /// outLined-styled button let outLinedButton: RippleableButton = { let button = RippleableButton(type: .outlined) button.layer.cornerRadius = 5 button.setTitle("Hello World!", for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18) button.primaryColor = .blue return button }() /// contained-styled button let containedButton: RippleableButton = { let button = RippleableButton(type: .contained) button.layer.cornerRadius = 5 button.setTitle("Hello World!", for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18) button.primaryColor = .blue return button }() /// text-styled button let textButton: RippleableButton = { let button = RippleableButton(type: .text) button.layer.cornerRadius = 5 button.setTitle("Hello World!", for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18) button.primaryColor = .blue return button }()
로 간단하게 물결 버튼을 생성할 수 있습니다.
버튼은 가능한 메터리얼 디자인의 아웃라인 / 컨테인드 / 텍스트 스타일을 모방하도록 했습니다.
https://github.com/sesang06/Rippleable
도움이 되셨다면 해당 라이브러리에 스타 하나 눌러 주시면 감사하겠습니다 :)
반응형'iOS' 카테고리의 다른 글
UICollectionViewDiffableDataSource, UITableViewDiffableDataSource 로 깔끔한 콜렉션 뷰 데이터 관리하기 (0) 2020.02.26 iOS, Android WebView 와 네이티브간의 유용한 통신 방법 - Javascript Interface, Webkit Messaging (0) 2020.02.20 Let Swift 2019 탐방 견문록, 정기 iOS 개발 행사를 엿보다 (0) 2019.11.17 iOS 13을 대응해야 하는 개발자가 알아야 하는 8가지 급한 불 리스트 (1) 2019.08.30 정보처리 산업기능요원을 생각한다면 반드시 숙지해야 할 사항들 (4) 2019.08.02