지난번에 이어서 PlayingCard 데모를 작성했다.

이번에는 PlayingCard, PlayingCardDeck 모델에 이어서 PlayingCardView ViewController를 작성하였다.


cornerLabel

가장 먼저 작성한 부분은 카드의 모서리에 문양(suit)과 숫자(rank)를 적는 것이었다.

private func centeredAttributedString(_ string:String, fontSize:CGFloat) -> NSAttributedString{
    //매개변수로 들어온 폰트 크기의 본문폰트 생성
    var font = UIFont.preferredFont(forTextStyle: .body).withSize(fontSize)
    //사용자 맞춤 폰트 크기로 변환
    font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font)
        
    //문단스타일에 가운데 정렬 설정
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .center
        
    //매개변수로 들어온 문자열과, 문단스타일, 폰트를 적용하여 NSAttributedString 리턴
    return NSAttributedString(string: string, attributes: [.paragraphStyle: paragraphStyle, .font: font])
}
    
private var cornerString: NSAttributedString{
    return centeredAttributedString(rankString+"\n"+suit, fontSize: cornerFontSize)
}
    
private func createCornerLabel() -> UILabel{
    let label = UILabel()
    label.numberOfLines = 0 //레이블의 줄 수가 0이면 무제한이 된다. (쓰는만큼 늘어남)
    addSubview(label)   //해당 레이블을 서브뷰로 추가한다.
    return label
}
    
private lazy var upperLeftCornerLabel: UILabel = createCornerLabel()
private lazy var lowerRightCornerLabel: UILabel = createCornerLabel()
    
//레이블 내용 및 사이즈 변경 함수
private func configureCornerLabel(_ label: UILabel){
    //레이블의 텍스트 내용 변경
    label.attributedText = cornerString
    //sizeToFit을 하기 전에 기존의 크기정보를 초기화. *sizeToFit()호출시 width가 따로 지정되어 있는 상태라면 width는 그대로 둔상태로 높이만 줄이기에 너비도 딱맞게 줄이고 싶다면 width의 값을 0으로 만들어야한다.
    label.frame.size = CGSize.zero
    //label 사이즈를 내용에 맞게 줄임
    label.sizeToFit()
        
    //카드가 앞면상태가 아니라면 isHidden으로 레이블을 가린다.
    label.isHidden = !isfaceUp
}

첫번째로 rank와 suit를 가지고서 문자열을 생성. 그리고 UILabel을 playingCardView에 서브뷰로 추가하는 작업이다. 일반적인 트럼프카드를 생각해보면 알수있듯이 카드의 문양과 숫자는 좌측상단과 우측하단에 위치한다. 따라서 UILabel을 두개를 생성하여 서브뷰로 추가하였다. 이후 생성된 서브뷰의 내용을 rank와 suit가 담긴 문자열로 변경 및 알맞은 사이즈로 수정하는 configureCornerLabel()함수를 생성하였다.


traitCollectionDidChange()

화면회전 및 폰트크기변경과 같은 뷰의 특성에 변화가 일어나면 자동으로 수행되는 함수이다.

//화면회전, 폰트크기변경 등의 특성에 변경이 일어나면 수행되는 함수
//글자크기의 변경이 일어났을 때, 이러한 변경을 바로 반영하기위해 오버라이딩하였다.
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    //폰트크기의 변화가 일어나면 바로 드로잉과 배치를 다시 시작하여 변경사항을 반영한다.
    setNeedsDisplay()
    setNeedsLayout()
}

화면의 회전 또는 사용자의 텍스트 크기변경 등 뷰에 변화가 일어날 때마다 자동으로 뷰를 재배치 및 드로잉이 다시 수행되도록 traitCollectionDidChange()함수를 오버라이딩 하였다. 이때 호출해야할 함수는 draw()와 layoutSubViews를 대신하여 호출하는 setNeedsDisplay()와 setNeedsLayout()함수이다.    *draw()함수와 layoutSubView()는 swift만이 호출하는 함수이기 때문이다.


layoutSubView()

뷰의 배치를 담당하는 함수이다. swift에 의해서만 호출이 된다. 

//서브뷰 배치 함수
override func layoutSubviews() {
    super.layoutSubviews()
    //레이블 내용 다시 그리기
    configureCornerLabel(upperLeftCornerLabel)
    //좌측상단 레이블 재배치
    upperLeftCornerLabel.frame.origin = bounds.origin.offsetBy(dx: cornerOffset, dy: cornerOffset)
        
    //우측하단 레비을 재배치
    configureCornerLabel(lowerRightCornerLabel)
    //affine변환 크기, 평행이동, 회전을 나타내는 변환. 비트단위 변환
    //회전의 경우 라디안(파이)를 단위를 이용한다.
    //좌측상단의 원점으로 회전을 하는 것이기에, 우리가 원하는 위치에 놓으려면 카드의 사이즈만큼 평행이동 후에 회전을 시켜야한다.
    lowerRightCornerLabel.transform = CGAffineTransform.identity
        .translatedBy(x: lowerRightCornerLabel.frame.size.width, y: lowerRightCornerLabel.frame.size.height)
        .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)
}

뷰의 레이아웃을 담당하는 layoutSubView()함수를 오버라이딩 하였다. super.layoutSubViews()가 호출된 이후에 수행할 동작들을 작성하면된다. 여기서 주목해야할 점은 좌측상단의 UILabel은 지정된 offSet(여백) 만큼만 뷰의 원점 좌표에서 이동을 하면 되지만, 우측하단의 UILabel의 경우, 180도 회전과 카드크기만큼의 평행이동이 이루어져야 한다.

아무 생각없이 offSet만큼 이동을 한뒤에 회전을 하게되면 사진과 같이 의도한 위치에 놓일 수 없게된다.

따라서 기존의 offSet만큼 이동한 위치에서 다시 한번 카드의 크기만큼 평행이동한 뒤에 원점을 기준으로 180도 회전을 해야한다.


draw( _ rect: CGRect)

화면의 드로잉을 위한 함수이다. UIView를 상속받는 view를 생성했다면 오버라이딩하여 구현하면 된다. 오로지 swift에 의해서만 호출이 되는 함수이다. 앞글에서 이야기했지만 draw를 통한 화면드로잉에는 크게 두가지 방법이있다. context를 이용하는 방법과 UIBezierPath()를 이용하는 방법이다. 

override func draw(_ rect: CGRect){
    //contect사용방식
    if let context = UIGraphicsGetCurrentContext(){
        context.addArc(center: CGPoint(x: bounds.midX, y: bounds.midY), radius: 100.0,
                       startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
        //startAngle, endAngle은 0~2라디안(파이)로 표현된다.
        context.setLineWidth(5.0)
        UIColor.green.setFill()
        UIColor.red.setStroke()

        //드로잉 컨텍스트의 경우 경로를 없애가면서 그린다. 따라서 먼저 수행된 strokePath가 앞에 선언된 경로를 지웠기에
        //fillPath에는 아무런 경로가 없어 드로잉을 하지 않게된다.
        context.strokePath()
        context.fillPath()
    }
		
    //UIBezierPath()사용방식
    let path = UIBezierPath()
    path.addArc(withCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: 100.0, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
    path.lineWidth = 5.0
    UIColor.green.setFill()
    UIColor.red.setStroke()
    //UIBezierPath의 경우는 컨텍스트방식과 다르다. 경로를 남긴채로 드로잉 하기때문에 겹처서 드로잉 하는것이 가능하다.
    path.stroke()
    path.fill()
}

두 방식 모두 사용방법은 같다. 드로잉할 경로를 설정한 뒤, stroke() 또는 fill()등 화면에 드로잉하는 함수를 호출하여 경로대로 그려나간다. 하지만 context방식은 경로를 따라 그리면서 이전의 경로는 지워지게 되게되어 같은 경로를 재사용할수 없는 반면, UIBezierPath()의 경우에는 같은 경로를 여러반 사용이 가능해진다. 따라서 같은 비슷한 경로를 여러번 그리는 상황이라면 UIBezierPath방법이 조금 더 손쉬울 것이다.

 

PlayingCardView.swift에서 가장 핵심함수인 draw(_ rect: CGRect)부터 보면 다음과 같다. 

override func draw(_ rect: CGRect) {
    //둥근모서리 모양 생성
    let roundedRect = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
    //설정한 모양에 맞게 클리핑
    roundedRect.addClip()
    //채우기 색상 흰색으로 설정
    UIColor.white.setFill()
    //채우기 수행
    roundedRect.fill()
        
    //카드의 뒷면 유무에 맞게 동작
    if isfaceUp{
        //카드이미지 생성. rankString+suit 형태의 이름의 이미지를 가져온다.
        //in: Bundle(for: self.classForCoder),compatibleWith: traitCollection 해당문구는 인터페이스빌더에 실시간 반영을 위해 추가한 부분이다.
        if let faceCradImage = UIImage(named: rankString+suit,in: Bundle(for: self.classForCoder),compatibleWith: traitCollection){
            //해당하는 카드의 이미지가 있다면,해당 이미지를 알맞은 비율로 축소하여 그려낸다. 0.75로 설정되어있다. 원본크기의 75% 크기이다.
            //이미지가 카드뷰보다 크면 안되기에..
            //faceCradImage.draw(in: bounds.zoom(by: SizeRatio.faceCardImageSizeToBoundsSize))
            //핀치 줌을 사용하기 위해 상수가 아닌 변수형태로 적용하기 위해 faceCardScale변수를 만들어서 코드를 수정했다.
            faceCradImage.draw(in: bounds.zoom(by: faceCardScale))
        }else{
            //이미지가 없는 카드라면 문양 드로잉
            drawPips()
        }
    }else{
        if let cardBackImage = UIImage(named: "cardback",in: Bundle(for: self.classForCoder),compatibleWith: traitCollection){
            cardBackImage.draw(in: bounds)
        }
    }
}

가장 첫번째로 직사각형의 뷰를 카드모양에 맞게 그려내는 것이다. UIBezierPath(roundedRect: , cronerRadius: )함수는 모서리의 곡률를 설정하여 모서리가 둥근 모양의 뷰를 드로잉 할 수 있다. 여기서의 곡률은 CGFloat형태의 값을 입력받게 되는데, 코드의 유지보수를 위해 상수로 설정하여 값을 입력하였다. 

카드모양의 윤곽선을 그려낸 후, 카드의 뒷면을 드로잉할지, 앞면을 드로잉 하게될지 isFaceUp의 값에 따라서 결정하게 된다. 카드의 뒷면, J, Q, K, A에 해당하는 카드의 경우, Assets 디렉토리안에 있는 이미지를 통해 불러오게 되고, 문양과 숫자로 표현되는 카드들은 drawPips()함수를 통해 숫자의 갯수에 해당하는 배열로 문양을 드로잉하게된다. 

private func drawPips(){
    //카드의 숫자를 인덱스로 삼아 해당하는 배열을 2차원 배열로 표현하였다.
    let pipsPerRowForRank = [[0],[1],[1,1],[1,1,1],[2,2],[2,1,2],[2,2,2],[2,1,2,2],[2,2,2,2],[2,2,1,2,2],[2,2,2,2,2]]
        
    func createPipString(thatFits pipRect: CGRect) -> NSAttributedString {
        let maxVerticalPipCount = CGFloat(pipsPerRowForRank.reduce(0) { max($1.count, $0) })
        let maxHorizontalPipCount = CGFloat(pipsPerRowForRank.reduce(0) { max($1.max() ?? 0, $0) })
        let verticalPipRowSpacing = pipRect.size.height / maxVerticalPipCount
        let attemptedPipString = centeredAttributedString(suit, fontSize: verticalPipRowSpacing)
        let probablyOkayPipStringFontSize = verticalPipRowSpacing / (attemptedPipString.size().height / verticalPipRowSpacing)
        let probablyOkayPipString = centeredAttributedString(suit, fontSize: probablyOkayPipStringFontSize)
        if probablyOkayPipString.size().width > pipRect.size.width / maxHorizontalPipCount {
            return centeredAttributedString(suit, fontSize: probablyOkayPipStringFontSize / (probablyOkayPipString.size().width / (pipRect.size.width / maxHorizontalPipCount)))
        } else {
            return probablyOkayPipString
        }
    }
        
    if pipsPerRowForRank.indices.contains(rank) {
        let pipsPerRow = pipsPerRowForRank[rank]
        var pipRect = bounds.insetBy(dx: cornerOffset, dy: cornerOffset).insetBy(dx: cornerString.size().width, dy: cornerString.size().height / 2)
        let pipString = createPipString(thatFits: pipRect)
        let pipRowSpacing = pipRect.size.height / CGFloat(pipsPerRow.count)
        pipRect.size.height = pipString.size().height
        pipRect.origin.y += (pipRowSpacing - pipRect.size.height) / 2
        for pipCount in pipsPerRow {
            switch pipCount {
            case 1:
                pipString.draw(in: pipRect)
            case 2:
                pipString.draw(in: pipRect.leftHalf)
                pipString.draw(in: pipRect.rightHalf)
            default:
                break
            }
            pipRect.origin.y += pipRowSpacing
        }
    }
}

참고해야할 점은 수업에서 직접 코드를 작성하지 않고 미리 작성한 코드를 복사한 터라 별도의 설명은 넘어갔는데, 눈에 띄었던 부분이 바로 드로잉 하야할 카드 문양의 배열을 2차원 배열을 통해 구현해놓았다는 점이다. 


@IBDesignable

뷰가 올바르게 동작하는지 확인하기 위해서는 계속해서 빌드를 하는 방법도 있지만, 인터페이스 빌더를 통해서도 확인이 가능하다. PlayingCardView 선언부분에 @IBDesignable이라는 키워드만 붙여주게되면 인터페이스빌더를 통해 바로 확인이 가능해지게 되는데, 해당 키워드 추가후, 인터페이스 빌더를 보면 Designable 항목이 추가된것을 확인할 수 있다. 해당 항목은 @IBInspectable 키워드가 붙어있는 변수들을 반영하게 되고, 이를 통해서 즉각적으로 확인이 가능하게 된다.

//현재 뷰의 모습을 인터페이스빌더에서 바로 확인이 가능하다. 단, UIImage(named)는 제대로 동작하지 않으니 참고할것.
//in: Bundle(for: self.classForCoder),compatibleWith: traitCollection 해당문구를 UIImage(named)함수에 추가하면,
//인터페이스빌더에 실시간 반영이 가능하다.
@IBDesignable
class PlayingCardView: UIView {
    //@IBInspectable -> 인터페이스빌더에서 해당 변수들을 추가할수있는 문구다. 이때는 타입추론이 아닌 명시적으로 자료형을 정해주어야한다.
    //카드의 문양과 숫자가 바뀌면 드로잉과 배치를 다시해야한다. 이떄 draw()함수를 직접 호출할수 없고, setNeedsDsiplay()를 호출하면 draw()가 호출된다.
    //setNeedsLayout()또한 재배치를 위한 함수이다. 우리가 직접 호출하지 못하는 layoutSubview()를 대신 호출한다.
    @IBInspectable var rank:Int = 5 { didSet {setNeedsDisplay(); setNeedsLayout()}}
    @IBInspectable var suit:String = "♥️" { didSet {setNeedsDisplay(); setNeedsLayout()}}
    @IBInspectable var isfaceUp:Bool = false { didSet {setNeedsDisplay(); setNeedsLayout()}}
    
    ...
    
}

 

해당 키워드를 사용한다면, 굳이 빌드하지 않고 인터페이스 빌더를 통해 손쉽게 동작을 확인할 수 있게 된다.


Gesture

사용자의 제스처를 감지하기 위해서는 UIGestureRecognizer 클래스를 사용해야한다. *정확히는 uiGestureRecognizer의 서브클래스들을 활용해야한다. 

 

제스처 감시는 크게 두동작으로 나뉜다.

1. UIView에 제스처감시자를 추가하는 것.     -> 주로 컨트롤러에 추가

2. 제스처를 감지했을 때 작동하는 핸들러를 작성하는 것.      -> 상황에 따라 UIView 또는 컨트롤러에 작성

 

UIGestrueRecognizer생성

@IBOutlet weak var pannableView: UIView{
	didSet{
    	let panGestureRecognizer = UIPanGestureRecognizer(
        	target: self, action : #selector(ViewController.pan(recognizer:))
        )	//감시자 생성, 생성자에 target과 action을 설정한다.
        pannableView.addGestureRecognizer(panGestrueRecognizer)	//해당 뷰에 감시자 추가
    }
}

여러 제스처 중 pan(좌우로 끄는 동작)제스처를 예시로 생성해보았다. 이용자의 제스처가 runtime에 반영이 되어야 하기때문에 didSet을 통해 감시자를 생성하게 된다. target은 제스쳐의 대상. 주로 뷰자신이나 컨트롤러를 적느다. action에는 제스처가 감지될경우 행동할 objc함수를 작성한다. 이후 해당 뷰에게 addGestureRecognizer() 함수를 호출시켜 원하는 감시자를 추가하면된다. 뷰는 여러 개의 감시자를 가질 수 있다.

 

핸들러 작성

제스처 감시자의 state 변수에 사용자의 제스처 상태가 담겨있다. 각 제스처에 해당되는 state의 상태를 switch문을 통해 동작을 정의하면 된다. 

func pan(recognizer: UIPanGestrueRecognizer){
	switch recognizer.state {
    	case .changed : fallthrough	//손을 움직이는 순간
        case .ended:	//손을 떼어낸 순간
            let translation = recognizer.translation(in: pannableView)
            recognizer.setTranslation(CGPoint.zero, in: pannableView)
        default: break	//나머지 경우
    }
}

fallthrough 키워드는 다음 case문을 동작하라는 키워드이다.

즉, pan동작의 단위 중 손이 움직이거나, 떼어낸 순간에 UIPanGestureRecognizer에 정의된 translation함수가 동작 하는 핸들러이다. 

 

UIPanGestureRecognizer에는 velocity(in: UIVIew)가 존재하며 이는 pan동작의 속도, translation()함수는 이동동작이 뷰의 어느지점에서 발생했는지 좌표를 알려주며, 

 

'iOS > Stanford' 카테고리의 다른 글

[Lecture 1] Getting Started with SwiftUI - 2  (0) 2023.06.22
[Lecture 1] Getting Started with SwiftUI - 1  (0) 2023.06.16
day05_view  (0) 2021.12.30
day04_swift_part2  (0) 2021.10.25
day03_swift_part1  (0) 2021.10.12

+ Recent posts