Lecture 6: Multitouch

sun·2021년 12월 31일

강의 링크

# PlayingCardView 만들기

  • 커스텀 뷰를 만드려면 UIView 의 서브클래스를 만든 다음, draw(_:) 메서드를 원하는 방식으로 오버라이딩 하면 된다.

  • 주의할 점은 뷰를 다시 그려야 할 때는 draw(_:) 메서드를 직접 호출하는 게 아니라 setNeedsDisplay() 혹은 setNeedsDisplay(_:) 메서드를 통해 간접 호출한다는 것. setNeedsDisplay() 메서드는 다시 그리라는 요구를 바로 입력받아 리턴하지만 실제 다시 그리는 작업은 (다시 그리기 위해) 무효화되었던 모든 view 가 다 다시 그려지는 다음 drawing cycle 때 이루어진다.

  • 커스텀 뷰를 그리기 위해서는 UIGraphicsGetCurrentContext() 메서드를 사용하는 방법과 UIBezierPath() 를 사용하는 두 가지 방법이 있는데 전자의 경우 strokePath() 를 쓰는 경우 path 자체가 소모되어 stroke 와 fill 을 동시에 하지 못한다. 따라서 후자가 활용도가 더 높은 것 같다!

    override func draw(_ rect: CGRect) {
        if let context = UIGraphicsGetCurrentContext() {
            context.addArc(
                center: CGPoint(x: bounds.midX, y: bounds.midY),
                radius: 100,
                startAngle: 0,
                endAngle: 2*CGFloat.pi,
                clockwise: true
            )
            context.setLineWidth(5)
            UIColor.green.setFill()
            UIColor.red.setStroke()
            context.strokePath()
            context.fillPath()  // path 가 소모되어 적용 X
        }
        
    override func draw(_ rect: CGRect) {
        let path = UIBezierPath()
        path.addArc(
            withCenter: CGPoint(x: bounds.midX, y: bounds.midY),
            radius: 100,
            startAngle: 0,
            endAngle: 2*CGFloat.pi,
            clockwise: true
        )
        path.lineWidth = 5
        UIColor.green.setFill()
        UIColor.red.setStroke()
        path.fill()
        path.stroke()
    }

# ContentMode

  • View 의 크기(bound)가 변할 때(e.g. 가로 모드로 전환) 내부 컨텐츠는 어떻게 조정할 지를 나타내는 플래그. 디폴트는 scaleToFill 으로 바뀐 크기에 맞게 컨텐츠를 늘리거나 줄이기 때문에 원래 설정했던 비율이 유지되지 않을 수 있다(바뀐 크기에 맞게 못생기게 찌부되거나 늘어난다고 생각하면 됨). 만약 크기가 바뀔 때마다 다시 그리고 싶다면 redraw 로 설정하면 된다!

# 불투명도

  • 투명도가 유효한 경우 렌더링하는데 expensive 하기 때문에 불투명도는 디폴트가 1도 설정된다. 따라서 투명도를 적용할 일이 생기면 Opaque = false 로 설정해줘야 한다. 아래와 같이 설정해주면 끝!
    • 강의에서는 카드 테두리를 둥글게 처리했는데 이걸 확인하려면 배경을 흰색에서 투명으로 바꿔줘야 해서 필요했다.

# Dynamic Font 와 가운데 정렬

  • 디바이스의 환경 설정에서 폰트 크기를 변경할 수 있는데, 이를 우리가 프로그램에서 고정값으로 설정한 폰트 크기에도 적용하려면 UIFontMetrics(forTextStyle:) 의 도움을 받을 수 있다.

  • 가운데 정렬은 NSMutableParagraphStyle() 의 alginment 프로퍼티를 사용해서 설정한다.

class PlayingCardView: UIView {
    private func centeredAttriutedString(_ string: String, fontSize: CGFloat) -> NSAttributedString {
        var font = UIFont.preferredFont(forTextStyle: .body).withSize(fontSize)
        font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font)  // Dynamic fonts 
        
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .center  // 가운데 정렬
        
        let attributes: [NSAttributedString.Key:Any] = [
            .paragraphStyle: paragraphStyle,
            .font: font
        ]
        
        return NSAttributedString(string: string, attributes: attributes)
    }
}

# setNeedsDisplay(), setNeedsLayout()

  • rank, suit, isFaceUp 이 바뀌면 View 를 다시 그려야 한다. 이때 위에서 말한 것 처럼 draw() 가 아니라 setNeedsDisplay() 를 호출해서 다시 그린다. 그리고 현재 UILabel 을 추가해서 좌상단과 우하단에 rank 와 suit 을 나타내고 있으므로 subview 들을 레이아웃도 업데이트해야 한다. 여기에는 setNeedsLayout() 을 쓰면 해결!
    • setNeedsLayout() 은 layoutSubviews() 를 간접 호출하는 방식
class PlayingCardView: UIView {
    var rank: Int = 5 { didSet{ setNeedsDisplay(); setNeedsLayout() } }
    var suit: String = "❤️" { didSet{ setNeedsDisplay(); setNeedsLayout() } }
    var isFaceUp: Bool = true { didSet{ setNeedsDisplay(); setNeedsLayout() } }
}

# UILabel 추가하기 (근데 이제 스토리보드 말고 코드를 곁들인)

  • addSubview(_:) 메서드를 사용하는데, 이렇게 추가된 view 는 다른 subview 들 보다 상위에 나타난다.
class PlayingCardView: UIView {
    private func createCornerLabel() -> UILabel {
        let label = UILabel()  // 인스턴스를 만들어서
        label.numberOfLines = 0
        addSubview(label)  // 추가 
        return label
    }
}

# sizeToFit()

  • sizeToFit() 메서드를 사용하면 해당 view 를 subview 들에 딱 맞는 크기로 조정한다. 주의할 점은 이전에 width 나 height 이 특정 크기로 설정되었다면 해당 값이 유지되기 때문에 size 를 0으로 초기화한다.
    • 개인적으로 처음에는 왜 bounds 가 아니고 frame 을 조정하는 지 궁금했다. 지금 크기를 조정하고 있는 건 CornerLable 이고 얘는 결국 PlayingCardView 의 subview 이므로 superview 의 관점에서 보는 게 맞다는 생각이 들었다.
class PlayingCardView: UIView {
    private func configureCornerLabel(_ label: UILabel) {
        label.attributedText = cornerString
        label.frame.size = CGSize.zero
        label.sizeToFit()
        label.isHidden = !isFaceUp
    }
}

# transform

  • 좌상단 cornerlabel 의 경우 포지셔닝이 간단한데, 우하단 cornerlabel 의 경우 좀 더 복잡하다. 왜냐하면 일단 맨 아래에 위치시켜야 하고, 180도 회전도 시켜야 하기 때문.

  • 이동할 때는 frame 의 origin, 즉 좌상단을 기준으로 하기 때문에 cornerOffset 만큼 빼주는 것 외에도 frame 자체의 크기만큼도 빼줘야 원하는 위치로 이동한다.

  • view 의 frame 을 회전시키거나 배율(scale)을 조정할 때는 transform 프로퍼티를 사용할 수 있다. 변화의 기준점은 frame 의 중점이다. 강의에서는 좌상단이라고 했는데 아마도 몇 년 사이에 바뀐듯...?

class PlayingCardView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        configureCornerLabel(upperLeftCornerLabel)
        configureCornerLabel(lowerRightCornerLabel)
        
        upperLeftCornerLabel.frame.origin = bounds.origin.offsetBy(dx: cornerOffset, dy: cornerOffset)
        
        
        lowerRightCornerLabel.transform = CGAffineTransform.identity
            .rotated(by: CGFloat.pi)  // 회전
        
        lowerRightCornerLabel.frame.origin = CGPoint(x: bounds.maxX, y: bounds.maxY)
            .offsetBy(dx: -cornerOffset, dy: -cornerOffset)
            .offsetBy(dx: -lowerRightCornerLabel.frame.size.width, dy: -lowerRightCornerLabel.frame.size.height)
    }
}

# traitCollection

  • 아까 폰트를 설정했을 때 분명히 디바이스 환경 설정에서 폰트 크기를 조정하면 맞춰서 변경되도록 했음에도 실제로 해보면 크기가 바뀌지 않는다. 정확히 말하면 폰트 크기는 바뀌었지만 view 가 아직 다시 그려지지 않은 것...!
  • iOS 인터페이스의 환경에 변화가 발생하면 시스템은 이에 반응하기 위해 traitCollectionDidChange(_:) 메서드를 호출한다. 이때, 폰트 크기 또한 traitCollection 에 포함되어 있어 디바이스에서 변경하는 경우 이 함수가 호출된다. 따라서 오버라이딩해서 우리가 필요한 작업(view 다시 그리기)을 수행하면 된다.
  • 오버라이딩하는 경우, super 를 호출해서 뷰 체계에서 상위에 있는 인터페이스 요소들이 먼저 레이아웃을 조정할 수 있도록 한다!
  • cf. UITraitCollection : iOS 인터페이스 환경
    • e.g.
      • horizontaol/vertical size class
      • display scale(e.g. 2.0이면 레티나 디스플레이)
      • user interface idiom
class PlayingCardView: UIView {
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        setNeedsDisplay()
        setNeedsLayout()
    }
}

# Priority

  • 아래와 같이 view 가 여러개의 constraint 를 갖는 경우 priority 를 설정해서 적용할 우선순위를 정할 수 있다.

  • 예를 들어서 가로 길이를 800 으로 하고 싶지만 만약 공간이 부족한 경우에는 그냥 5:8 이라는 비율을 지키는 것을 우선으로 하고 싶으면 전자의 중요도를 낮추면 된다! 참고로 디폴트는 1000 이다.


# centerpiece 그리기

  • 이미지를 Assets 에 추가한 다음 UIImage(named:) 메서드를 사용해서 불러온다. 그러고서 draw(in:) 메서드를 사용하면 원하는 영역을 이미지를 채울 수 있다!

  • 적당한 사이즈를 도출하는 메서드가 좀 어려웠는데(교수님이 제공하심) 일단 슥 보고 넘겼다...

class PlayingCardView: UIView {
    override func draw(_ rect: CGRect) {
        let roundedRect = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
        roundedRect.addClip()
        UIColor.white.setFill()
        roundedRect.fill()
        
        if isFaceUp {
            if let faceCardImage = UIImage(named: rankString+suit) {
                faceCardImage.draw(in: bounds.zoom(by: SizeRatio.faceCardImageSizeToBoundsSize))
            } else {
                drawPips()
            }
        } else {
            if let cardBackImage = UIImage(named: "cardBack") {
                cardBackImage.draw(in: bounds)
            }
        }
}

# Interface Builder

  • UIView 앞에 @IBDesignable 을 붙이면 Interface Builder 에서도 해당 view 의 모습을 바로 확인할 수 있다.

  • 이때 UIImage(named:) 로 만든 이미지는 Interface Builder 에 나타나지 않기 때문에 다른 init, 즉 UIImage(named:in:compatibleWith:) 을 사용한다.

@IBDesignable
class PlayingCardView: UIView {

    override func draw(_ rect: CGRect) {
        let roundedRect = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
        roundedRect.addClip()
        UIColor.white.setFill()
        roundedRect.fill()
        
        if isFaceUp {
            if let faceCardImage = UIImage(named: rankString+suit, in: Bundle(for: self.classForCoder), compatibleWith: traitCollection) {
                faceCardImage.draw(in: bounds.zoom(by: SizeRatio.faceCardImageSizeToBoundsSize))
            } else {
                drawPips()
            }
        } else {
            if let cardBackImage = UIImage(named: "cardback", in: Bundle(for: self.classForCoder), compatibleWith: traitCollection) {
                cardBackImage.draw(in: bounds)
            }
        }
    }
}
  • 이때 rank, suit, isFaceUp 을 InterfaceBuilder 에서 설정할 수 있다면 view 를 확인하는 데 매우 편할 것이다! 바꾸고 싶은 변수 앞에 @IBInsepectable 만 붙여주면 끝!!!
@IBDesignable
class PlayingCardView: UIView {
    
    @IBInspectable
    var rank: Int = 11 { didSet{ setNeedsDisplay(); setNeedsLayout() } }
    @IBInspectable
    var suit: String = "❤️" { didSet{ setNeedsDisplay(); setNeedsLayout() } }
    @IBInspectable
    var isFaceUp: Bool = true { didSet{ setNeedsDisplay(); setNeedsLayout() } }
}

# 제스처

  • UIGestureRecognizer 의 인스턴스들을 이용해서 제스처를 인식한다.
    • UIGestureRecognizer 자체는 추상화되어 있고, 실제 인식에는 구체화된 서브클래스를 사용
  • gesture recognizer 를 사용하기 위해서는
    1. UIView 에 gesture recognizer 를 추가하고
      - 즉, 특정 UIView 에게 어떤 gesture 를 인식하라고 요청
      - 보통 controller 에게 요청하나, 만약 해당 gesture 가 UIView 에 필수적이라면(e.g. scrollview 에서 swipe) UIView 가 직접 제스처를 인식하기도 한다

    2. 인식한 gesture 를 handle 할 메서드를 제공한다
      • Model 에 영향을 미치는 경우 Controller 가, view 와만 관련이 있는 경우 UIView 가 제공
  • 특정 gesture 에 대한 handler 는 action 을 하기 위해 해당 gesture 와 관련된 정보가 필요하기 때문에 구체화된 gesture recognizer 들이 여기에 필요한 메서드(e.g. translation, velocity etc.)들을 제공한다.
    • 점진적으로 변화가 이루어지도록 하려면 기준점의 리셋이 필요한데, 이를 위한 메서드도 제공한다.(e.g. setTranslation(_:, in:))
    • e.g. translation 을 제스처가 한번 업데이트 될 때마다 0으로 초기화함으로써 점진적 변화가 가능
  • 추상화된 슈퍼클래스도 state 정보를 제공한다
    • var state: UIGestureRecognizerState { get }
    • 인식 전에는 .possible 상태
    • 연속 제스처(e.g. pan)의 경우 .began 으로 시작해서 여러 번의 .changed 를 거쳐 .ended 로 끝남
    • 불연속 제스처(e.g. swipe)의 경우 .ended 혹은 .recognized 로 바로 감
    • .failed 혹은 .cancelled(연속만 해당) 상태가 될 수도 있으므로 주의 필요
    • handler 는 state 가 바뀔때마다 호출됨

불연속 제스처는 action(i.e. handler) 을 한 번만 호출하지만, 연속 제스처는 점진적인 매 변화마다 action 을 호출한다.




# Swipe

  • PlayingCardView 를 swipe 하면 다른 카드로 넘어가는 제스처를 추가하려고 한다. 이를 위해서는 먼저 ViewController 가 PlayingCardView 와 소통할 수 있도록 outlet 을 추가한다. iOS 가 런타임에 oulet 을 연결하면 didSet 이 호출되므로 여기에 gesture recognizer 를 추가해서 swipe 을 인식할 수 있게 한다.

  • 카드를 넘기는 것이므로 모델에 영향을 주기 때문에 target 은 Controller 다. 이때 주의할 점은, UIGestureRecognizer 는 objective-c 를 기반으로 하기 때문에, action 인자로 사용될 메서드(nextCard())는 반드시 @objc 를 앞에 붙여서 objective-c 런타임에 사용될 수 있게 해줘야 한다. 마지막으로 addGestureRecognizer(_:) 메서드를 이용해 UIGestureRecognizer 를 UIView(여기서는 playingCardView) 에 연결해서 swipe 를 인식하도록 한다.

class ViewController: UIViewController {
    private var deck = PlayingCardDeck()

    @IBOutlet weak var playingCardView: PlayingCardView! {
        didSet {
            let swipe = UISwipeGestureRecognizer(target: self, action: #selector(nextCard))
            swipe.direction = [.left, .right]
            playingCardView.addGestureRecognizer(swipe)
        }
    }
    
    @objc func nextCard() {
        if let card = deck.draw() {
            // Model 과 View 간의 변환 수행!
            playingCardView.rank = card.rank.order
            playingCardView.suit = card.suit.rawValue
        }
    }
}

# Tap

  • 스토리보드를 사용해서 제스처를 추가할 수도 있는데 TapGestureRecognizer 를 PlayingCardView 위에 드래그 앤 드랍하면 이렇게 스토리보드에 Tap Gesture Recognizer 가 추가된다.
  • 우리가 맨날 하던 ctrl + drag 를 통해 바로 action 을 추가할 수 있다. 주의할 점은 항상 switch 문을 통해 state 를 확인하고 그에 알맞은 작업을 수행하도록 해야 한다.
class ViewController: UIViewController {
    @IBAction func flipCard(_ sender: UITapGestureRecognizer) {
        switch sender.state {
        case .ended: playingCardView.isFaceUp.toggle()
        default: break
        }
    }
}

# Pinch

  • faceCardImage 의 크기를 상수에서 faceCardScale 변수로 바꿔서, pinch 정도에 따라 크기가 바뀌도록 해줄거다. faceCardImage 의 크기는 Model 과 무관하므로 handler 를 PlayingCardView 에 바로 추가한다.

  • handler 를 들여다보면, state 가 .changed 거나 .ended 일 때, faceCardImage 에 recognizer 의 scale 만큼 곱해준다. 이때, 마지막에 recognizer.scale = 1.0 으로 설정하는데, 이는 scale 이 마지막 상태에 대한 상대적인 크기를 나타내는 게 아니라 최초 상태로부터 몇 배인지를 나타내는 절대적 숫자이기 때문에 1.0 으로 매번 초기화해서 기하급수적으로 변동하는 것을 막기 위해서다!

  • 참고로 faceCardScale 이 바뀔 때, setNeedsLayout() 은 호출하지 않는 이유는 faceCardImage 의 크기 변화는 corner 에 영향을 미치지 않기 때문!

class PlayingCardView: UIView {
    var faceCardScale: CGFloat = SizeRatio.faceCardImageSizeToBoundsSize { didSet{ setNeedsDisplay() } }
    
    @objc func adjustFaceCardScale(byHandlingGestureRecognizedBy recognizer: UIPinchGestureRecognizer) {
        switch recognizer.state {
        case .changed, .ended:
            faceCardScale *= recognizer.scale
            recognizer.scale = 1.0
        default: break
        }
    }
    
    override func draw(_ rect: CGRect) {
        ...
        
        if isFaceUp {
            if let faceCardImage = UIImage(named: rankString+suit, in: Bundle(for: self.classForCoder), compatibleWith: traitCollection) {
                // 여기를 변경
                faceCardImage.draw(in: bounds.zoom(by: faceCardScale))
            }
            ...
    }
}

  • 그러고 나서 이제 outlet 의 didSet 에서 pinch 도 인식하도록 추가해주면 되는데, 이번에는 target 이 playingCardView 라는 차이가 있다.
class ViewController: UIViewController {
    @IBOutlet weak var playingCardView: PlayingCardView! {
        didSet {
            let swipe = UISwipeGestureRecognizer(
                target: self,
                action: #selector(nextCard)
            )
            swipe.direction = [.left, .right]
            playingCardView.addGestureRecognizer(swipe)
            
            // pinch 추가
            let pinch = UIPinchGestureRecognizer(
                target: playingCardView,
                action: #selector(PlayingCardView.adjustFaceCardScale(byHandlingGestureRecognizedBy:))
            )
            playingCardView.addGestureRecognizer(pinch)
        }
    }
}

☀️ 느낀점

  • 2021 마지막 강의...새로 배우는 내용이 많아서 생각보다 오래 걸렸다. SwiftUI 보다 제스처 다루는 건 더 쉬운 것 같기도 하다ㅋㅋㅋ 2022도 파이팅🥳
profile
☀️

0개의 댓글