커스텀 뷰를 만드려면 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()
}


디바이스의 환경 설정에서 폰트 크기를 변경할 수 있는데, 이를 우리가 프로그램에서 고정값으로 설정한 폰트 크기에도 적용하려면 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)
}
}
class PlayingCardView: UIView {
var rank: Int = 5 { didSet{ setNeedsDisplay(); setNeedsLayout() } }
var suit: String = "❤️" { didSet{ setNeedsDisplay(); setNeedsLayout() } }
var isFaceUp: Bool = true { didSet{ setNeedsDisplay(); setNeedsLayout() } }
}
class PlayingCardView: UIView {
private func createCornerLabel() -> UILabel {
let label = UILabel() // 인스턴스를 만들어서
label.numberOfLines = 0
addSubview(label) // 추가
return label
}
}
class PlayingCardView: UIView {
private func configureCornerLabel(_ label: UILabel) {
label.attributedText = cornerString
label.frame.size = CGSize.zero
label.sizeToFit()
label.isHidden = !isFaceUp
}
}

좌상단 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)
}
}
class PlayingCardView: UIView {
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setNeedsDisplay()
setNeedsLayout()
}
}
아래와 같이 view 가 여러개의 constraint 를 갖는 경우 priority 를 설정해서 적용할 우선순위를 정할 수 있다.

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

이미지를 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)
}
}
}
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)
}
}
}
}

@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() } }
}

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



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
}
}
}

class ViewController: UIViewController {
@IBAction func flipCard(_ sender: UITapGestureRecognizer) {
switch sender.state {
case .ended: playingCardView.isFaceUp.toggle()
default: break
}
}
}
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))
}
...
}
}
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)
}
}
}