ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • IOS 에서 머터리얼 디자인의 물결 이펙트 버튼 만들기
    iOS 2019. 12. 26. 01:35

    안드로이드의 기본 컴포넌트 버튼 Button의 경우, 별다른 설정을 하지 않아도 클릭할 때 물결 모양이 나타납니다. 반면에, iOS 버튼 UIButton의 경우, 클릭할 시에 물결 설정이 없습니다.

    https://developer.apple.com/documentation/uikit/uicontrol/1618231-ishighlighted

     

    isHighlighted - UIControl | Apple Developer Documentation

    Instance Property isHighlighted A Boolean value indicating whether the control draws a highlight. Declarationvar isHighlighted: Bool { get set } DiscussionWhen the value of this property is true, the control draws a highlight; otherwise, the control does n

    developer.apple.com

    물론, UIControl 의 isHighlighted 을 활용해서, 하이라이트된 경우 백그라운드의 색상에 포인트를 준다든지, 진동을 준다든지 하면 어느 정도 포인트를 줄 수 있습니다만, 약간 재미가 없던 것이 사실입니다.

    괜찮은 iOS 리플 이펙트의 소스를 찾아보려고 했습니다만, 잘 찾을 수 없었기에 그냥 한번 짜보자 생각해 보게 되었습니다.

    리서치 중 가장 참고가 된 것은 민소네님이 올린 리플 터치 이펙트 소스 코드입니다.

    생각보다 많은 것이 정리되어 있었고, 여기서 충분한 아이데이션을 얻을 수 있었던 것 같습니다.

     

     

    http://minsone.github.io/mac/ios/ripple-touch-effect-like-google-material-design-on-ios

     

    [iOS][Swift]구글 매터리얼 디자인의 물결 효과 만들기

    가끔씩 매터리얼 디자인의 물결 효과를 보면서 iOS에 적용해볼까 했지만, 이 효과때문에 Material 라이브러리를 추가해야하나 했습니다. 그래서 CALayer를 이용해서 구현해보았습니다. class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let tap = UITapGestureRecognizer(target: self, action: #sel

    minsone.github.io

     

     

    물결 효과를 주는 레이어를 따로 정의하고, 클릭 시에 CABasicAnimation으로 알파값, 트랜스폼값, 백그라운드 컬러 값등을 조절해서 만드는 방식이었습니다.

    여기서 아이데이션을 얻어 처음에 버튼을 만들어 보았지만, 제가 원하는 메터리얼 디자인의 물결을 정확히 모방하기에는 역부족이라는 생각이 들었습니다.

     

    https://material.io/components/buttons/

     

    Buttons

    Buttons allow users to take actions, and make choices, with a single tap.

    material.io

     

    결국 구글에서 제공하는 머터리얼 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

     

    sesang06/Rippleable

    Ripple-Effecting Button in Swift, Inspired by material ios design - sesang06/Rippleable

    github.com

    도움이 되셨다면 해당 라이브러리에 스타 하나 눌러 주시면 감사하겠습니다 :)

Designed by Tistory.