지난번에 이어서 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

error handling

코드를 작성하다 보면 에러를 마주치게 되는데 에러에 대한 처리방법에는 크게 3가지가 있다.

do {
	try something.method()
} catch let error {
	throw error
}

우선 do - catch 문법이다. try를 통해 수행된 함수가 error를 발생할 시, error라는 지역 변수에 담기게 되고 우리는 throw 를 통해 해당 에러가 무엇인지 확인하게 된다. 지역변수 error는 NSError 클래스 타입이며 여기에는 에러를 다룰 수 있는 여러가지 메소드가 정의되어 있다. 우리는 error변수에 접근하여 해당하는 조치를 취해주면 되는 것이다. 

 

try! something.method()

something.method()의 강제실행 구문이다. 훨씬 간결하게 적을 수 있지만, 에러가 발생할 경우 앱의 강제종료로 이어진다. 현재 환경에서 에러가 발생하지 않는 다는 확신이 있을 때만 사용해야 한다. 안전하게 사용하고 싶다면 최대한 사용을 피해야하는 표현이다.

 

try? something.method()
if let data = try? getData(){
	return getData()
 }

가장 이상적인 방법으로는 try? 구문이 있다. 에러가 발생하면 nil을 반환하고 리턴값이 있는 메소드라면 optional 타입을 반환하게 된다. 예시와 같이 에러없이 수행된다면 nonOptional타입을 반환할 수 도 있다.


Any, AnyObject

Any 타입은 어느타입이든 될 수 있는 특징을 가진 타입이다. 자료형을 꼭 명시해줘야 하는 swift에게는 어울리지 않는 타입인데, Any타입은 objc와의 호환성을 위해 존재하는 타입이라고 볼 수 있다. 

func prepare(for segue: UIStroyboardSegue, sender: Any?)

화면전환을 위한 함수 prepare함수를 보면 sender가 Any? 타입으로 되어있는 것을 볼 수 있다. 버튼을 눌렀을 경우 전환이 된다면 버튼이 sender가 될 수 있는 것이고, 테이블 뷰의 행을 클릭했을 때 변환이 되는 경우는 tableViewCell이 sender가 될수도 있는 것이다. 이때 어떤 자료형이 올 수 있는지 알 수 없으니 Any를 사용한다.


as?

Any의 단점은 swift의 엄격한 타입 관리로 인해 Any타입은 메세지나 변수를 보낼 수 없다는 것이다. 따라서 Any타입을 사용하려면 타입의 변환이 이루어져야 하는데, 이때 사용하는 키워드가 as?이다. 타입변환에 실패할 시 nil이 반환된다.

if let unknow:Any = ...
if let foo = unknown as? myType{
	//myType으로의 변환이 성공시의 영역
	//myType의 메소드나, 변수에 foo로서 접근이 가능하다.
	//만일 myType으로의 변환에 실패할 경우 해당 영역에 접근할 수 없다.
}else{
	//타입변환에 실패할 경우 진입하는 영역이다.
}

즉 어떠한 타입일지 모르는 변수 unknown을 myType으로 추측하여 foo에 담은 뒤, myType인 변수들이 알아들을 수 있는 메세지를 foo의 영역안에 보낼 수 있는 것이다. myType의 데이터가 아니어서 변환에 실패할 경우 nil을 반환하기에 부담이 적다. 예시처럼 실패할 경우의 행동을 취할 수 도 있다.


NSObject

swift는 objc를 대체하기 위해 만들어진 언어이다. 앞서 언급했던 Any의 경우도 objc의 호환성을 위해 만들어진 타입인데, 그렇다면 objc의 특성에 대해 어느정도 알아 둘 필요가 있다. 모든 objc의 클래스는 NSObject라는 클래스를 상속받는다. 즉 NSObject는 모든 objc 클래스의 루트 클래스라는 이야기이다. objc에서 클래스를 생성하게 되면 해당 클래스는 반드시 NSObject를 상속박아야 한다. 애초에 런타임이 그렇게 요구하도록 만들어져 있다.

NSNumber

objc에서 숫자를 다룰 때 사용하는 클래스이다. 정수형, 실수형 등 모든 숫자 형태는 NSNumber로 전달이 가능하고, 전달 받은 데이터를 swift에서 사용할 경우 알맞는 타입으로 변환해주면 된다. 

let number = NSNumber(35.5)
let intified:Int = n.intValue

Date

날짜,시간을 표현하는 타입

1970년 이후 얼마나 시간이 흘렀는지를 게산한다. 백만분의 1초 단위로 측정가능.

이외에도 Calender, DateFormatter, DateComponents와 같은 날짜표시 클래스들이 존재한다.

날짜를 바로 UI에 표시하게 되면 지역마다 다른 날짜 체계에 혼동이 오게되어서 사용하는 클래스이다.


Data

ios API간 데이터를 전송할 때 사용하는 클래스이다. 

각 자료의 형태에 맞는 Data구조를 제공한다.


Views

MVC에서의 뷰가 아닌, swift에서 UIView의 서브클래스들을 지칭한다. 즉 화면에 표현되는 좌표계를 동반한 사각형 화면을 이야기하는 것이다. view는 계층적 구조로 이루어져 있으며, UIView를 하위로 여러개의 서브뷰들이 스택으로 쌓여져 사용자에게 보여지는 화면이 완성되는 것이다. UI에 존재하는 버튼, 텍스트 등 모두 뷰에 해당한다.

타입은 UIView의 형태로 생성이 되며, 이렇게 계층구조를 이루고 있는 뷰는 각자에게 해당하는 상위(슈퍼)뷰나 하위(서브)뷰를 요청할 수 있다. 이는 당연히 있을수도 있고 없을수도 있으니 옵셔널 형태인 UIView?의 형태로 리턴이 된다.

 

이러한 뷰의 계층구조를 작성하는 방법은 크게 두가지가 있다. 첫번째는 우리가 흔히 사용했던 xcode의 인터페이스빌더를 통한 방법이다. 두번째는 코드를 통해 작성하는 방법이 있는데, 가장 핵심적인 두 메소드를 통해 서브뷰의 추가 및 삭제가 이루어진다.

func addSubview(_ view:UIView)
//서브 뷰 추가 메소드

func removeFromSuperView()
//해당 뷰 삭제 메소드

메소드의 이름을 보면 알 수 있듯이 서브뷰 추가 메소드는 추가할 대상이 되는 상위 뷰에서 호출, 뷰의 삭제 메소드는 삭제할 대상이 되는 서브뷰에서 호출을 하면 된다. 

 

view hierarchy(계층구조)의 가장 최상단 뷰는 바로 뷰컨트롤러의 view:UIView 변수이다. 자동으로 생성이 되며, 프로젝트의 초기 생성기 최상단의 검정색 뷰이다. 초기의 화면 구성을 위해 바로 이 view 변수에 접근하여 서브뷰를 추가하면 된다. 

 

Initializing a UIView

뷰를 초기화 하는 방법에는 두가지 메소드가 있다. 

init(frame:CGRect) 
//UIView가 코드로 생성된 경우 사용

init(coder:NSCoder)
//UIView가 스토리보드(인터페이스빌더)로 생성된 경우

 하지만 init()을 사용한 초기화는 비추천한다고 한다. 직접 상속을 받아 구현해야하는 부분이 많고 구현을 하다보면 코드가 겹치는 부분이 생기기 때문이라고 하는데, 그래서 사용하는 방법이 awakeFromNib()이라는 함수이다. 

해당 함수는 스토리보드에서 생성된 모든 객체들이 초기화가 이루어졌을 때 호출되는 함수이다. 즉 코드로 생성된 뷰는 해당 방법으로는 초기화 할 수 없으니 참고하도록 하자. *Nib는 인터페이스빌더의 옛이름이다.


Coordinate System Datastructure

드로잉 하기 위한 중요한 4가지 자료형이 있다.

모두 CG로 시작을 하는데, 이는 CoreGraphic를 의미한다. ios에서 일반적인 2차원 드로잉을 위한 시스템이다.

 

CGFloat

부동소수점으로 좌표를 표현하는 기본 자료형이다. 화면의 좌표를 정수형태가 아닌 부동소수점으로 나타내며, Double, Float과는 다른 자료형이다. 초기화 함수가 있으므로 Double타입에서 CGFloat으로 타입 캐스팅이 가능하다. 드로잉을 하는 가장 기본적인 자료형으로 드로잉에 관련된 모든 곳에는 CGFloat타입을 사용한다.

 

CGPoint

CGFloat타입의 x,y변수가 담겨있는 구조체이다. 좌표가 담겨있는 CG 구조체라고 보면 된다.

var point = CGPoint(x:37.0, y:55.2)
point.y += 1
point.x -= 0.5

 

CGSize

CGFloat타입의 width와 height가 담겨있는 구조체이다. 표현하려는 뷰의 높이와 너비정보를 담는다.

var size = CGSize(width: 100.0, height: 50.0)
size.width += 42.5
size.height += 75

 

CGRect

CGPoint와 CGFSize를 담고있는 구조체이다. 즉 좌표와 사이즈가 담긴구조체로 어느위치에 어느크기로 드로잉을 해야 하는지 정보를 담을 수 있다. Rect는 rectangle, 사각형을 의미한다.

struct CGRect{
	var origin : CGPoint	//좌표정보
	var size : CGSize	//크기정보
}
let rect = CGRect(origin: aCGPoint, size: aCGSize)	//초기화

var minX: CGFloat	//좌측테두리
var midY: CGFloat	//수직 중간점
intersects(CGRect) -> Bool	//다른 CGRect와의 영역간섭이 있는지 확인하는 함수
contains(CGPoint) -> Bool	//해당 좌표를 CGRect가 포함하고있는지 확인 하는 함수
//이외에 여러가지 함수와 변수들이 있다.

origin에는 좌표정보를, size에는 크기정보를 담을 수 있다. 이외에 드로잉에 관련된 함수와 변수가 많다.


View Coordinate System

수학에서의 좌표는 좌측하단이 원점(0,0)이다. 하지만 스위프트의 View Coordinate System의 경우 화면의 좌측상단이 원점(0,0)이다. 

y값이 증가하면 아래로, x값이 증가하면 오른쪽으로 이동이 된다.

 

화면표현의 단위는 픽셀(pixel)이 아닌 포인트(point)이다. 둘 사이에는 엄연한 차이점이 있는데, 우선 픽셀은 스크린을 구성하고 있는 작은 점이다. 일반적인 스크린 표현에는 하나의 점에 하나의 픽셀이 할당되는데에 비해 스위프트의 좌표계는 포인트당 1~3개의 픽셀이 배치되어 포인트의 경계에도 드로잉을 할 수 있는데, 이것이 부동소수점을 사용하는 이유이다. 이는 보다 부드러운 곡선을 표현하는데에 더욱 유리하며, 저해상도 디스플레이에서는 당연히 픽셀의 갯수가 적기 때문에 포인트당 하나의 픽셀을 할당해야 하며 이는 부드러운 곡선을 표현하기에 불리하다. apple에서 레티나디스플레이 라고 부르는 고해상도 디스플레이를 활용하기 위해 이러한 포인트 단위로 사용한다고 보면 되겠다. 

 

bounds & frame

뷰에 있어서 중요한 두가지 변수가 있다. bounds와 frame이다. 최근 공채 면접에서 두가지 변수의 차이점에 대해서 물어보기도 했다. 

var bounds: CGRect
var frame: CGRect

두 변수 모두 CGRect 타입인건 동일하지만, 쓰임새가 전혀 다르다. 

UIView는 각자 서로 다른 좌표계를 가진다. 즉 자기 자신의 고유한 원점과 크기를 담은 변수가 bounds이며 이는 곧 뷰 안에서 드로잉 할 수 있는 영역의 범위를 뜻하기도 한다. 

 

frame의 경우 해당 뷰가 상위뷰의 어느 지점에 있는지를 나타낸다. 즉 해당하는 뷰가 속해있는 상위 뷰의 원점에서 어느정도 떨어져 있는지 나타내는 좌표를 반환하는 것이다. 비슷한 변수로는 center:CGPoint 변수도 있다. 해당 뷰가 속한 상위뷰의 중심좌표를 반환한다.

 

결론은 frame과 center는 드로잉하고 있는 곳이 어딘지를 반환하는 변수이고, bounds는 지금 드로잉을 하는 곳을 반환한다고 보면 된다.

 

frame과 bounds의 크기는 같다고 생각 할 수 있겠지만, 뷰가 회전하는 경우를 생각해보면 아니다.

뷰 B의 bounds는 ((0,0), (200,250)) frame은 ((140,65),(320,320)) center는 (300,225)가 된다.

그렇다면 B의 중심점은 어떻게 알 수 있을까? 바로 CGRect에 담겨있는 midX와 midY변수를 통해 알 수 있다. 

(bounds.midX, bounds.midY) = (100, 125)


Creating Views

일반적으로 뷰를 추가 하는 방법은 스토리보드에서 뷰를 추가하는 것이다. 하지만 이미 정해진채로 제공되는 뷰가 아닌 내가 직접 커스텀 한 뷰를 추가하고싶을때는 어떻게 해야 할까?

첫번째로 오브젝트 리스트에서 view라고 적혀있는 가장 일반적인 UIView를 꺼내온뒤, Identity indpector에서 내가 따로 작성한 클래스로 변경해주면 된다. 

물론 스토리 보드가 아닌 코드로 작성하여 추가 하는 방법도 있다. 

let newView = UIView(frame: myViewFrame)
//frame을 지정해서 추가할 경우

let newView = UIView()
//이 방법으로 생성시 해당 뷰의 frame은 ((0,0)(0,0))으로 설정되어 생성된다.

예시를 보면 이해하기 더 쉬울것이다. 

let labelRect = CGRect(x: 20, y:20, width: 100, height: 50)	//label을 담을 frame생성
let label = UILabel(frame: labelRect)	//UILabel은 UIView의 서브클래스다.
label.text = "Hello"

view.addSubView(label)	//화면에 표시하기 위해 UIView에 서브뷰로 추가한다.

lavel의 frame으로 지정할 labelRect를 (20,20) 좌표에 (100,50) 사이즈로 생성. label을 UIView를 상속받는 UILabel로 생성. frame을 lavelRect를 지정하여 초기화한다. 이후 text를 지정한다.

viewController에서 설정한다는 가정 하에, view는 아까 설명했듯이 뷰에 있어서 가장 최상단 UIView를 가리키는 변수이다. 즉 화면에 표시하기 위해서 view의 서브뷰로 추가하기 위해 assSubView()함수를 사용하여 (20,20)좌표에 (100,50)크기로 label을 추가해주는 것이다.


Custom Views

그렇다면 UIView를 상속받는 UILabel 이나 UIButton이 아닌 커스텀 뷰를 언제 사용하는 것일까? 일반적으로 본인이 원하는 드로잉이나 특별히 원하는 터치 이벤트를 다룰때 커스텀 클래스를 적용하게 된다. 이번에는 드로잉에 관해서만 다루는데, 바로 UIView에 정의되어있는 draw()함수를 오버라이드하는 것이다.

override func draw(_ rect: CGRect)

매개변수인 rect는 드로잉 영역으로서 사용된다. 화면 사이즈를 입력해도 되지만, 자원 사용량이 높은 경우(3d 그래픽 처리 등..) 최적화를 위한 공간 할당이 필요할 수 있다.

여기서 주의할 점은 draw(rect: CGRect)함수를 직접 호출해서는 안된다는 것이다. draw(rect: CGRect)를 호출하는 것은 오로지 ios뿐이다. 그렇다면 뷰를 수정하기 위해 새로 드로잉 하려면 어떻게 해야하나? 라는 질문이 생길텐데, 바로 setNeedsDisplay() 또는 setNeedsDisplay(_ rect: CGRect) 함수의 호출을 통해 ios에게 드로잉을 다시 하고 싶다고 알리는 방법 뿐이다. 여기서도 매개변수로 rect를 받는것 또한 다시 그려야할 영역을 정해줌으로써 자원사용량을 낮추기 위한 용도이다. 자원사용량이 많지 않아 전체 드로잉을 해도 상관없다면 rect변수 없이 호출해도 상관없다.

 

implements of draw(rect: CGRect)

그렇다면 draw(rect: CGRect)함수는 어떻게 구현할 수 있을까? 크게 두가지 방법으로 구현이 가능하다. 

 

첫번째로UIGraphicsGetCurrentContext()를 사용하는 방법이다.

 

코어그래픽스는 컨텍스트(문맥)을 기반으로 드로잉을 하게 된다. 이때 컨텍스트를 받아오는 함수가 UIGraphicsGetCurrentContext()이다. draw(rect:CGRect)에서 호출이 가능하며, 호출을 하게 되면 컨텍스트에 경로를 지정해주어야 한다. 경로에는 곡선(arcs)을 그릴지 혹은 직선(lines)을 그릴지의 여부부터 움직이는 move(to)와 같은 것들이 포함되어있다. 

 

경로를 설정하고 나면 드로잉의 성질을 설정하게 되는데, 드로잉의 성질에는 colors, fonts, textures, linewidths 와 같은 외형에 해당 하는 부분을 설정할 수 있다. 

 

마지막으로 경로에 대한 테두리 그리기와 채워넣기 둘 중 하나를 선택하게 된다.

 

다음으로 UIBezierPath 객체를 사용하는 방법이다. 

UIBezierPath는 컨텍스트를 자동으로 가져온다는 것 외에는 UIGraphicsGetCurrentContext()를 사용하는 것과 동일하다. 

 

let path = UIBezierPath()
path.move(to: CGPoint(80, 50))	//80,50 좌표로 이동
path.addLine(to: CGPoint(140, 150))	//140,150지점까지 직선추가
path.addLine(to: CGPoint(10, 150))	//10,150지점까지 직선추가
path.close()	//경로닫기 (원점으로 복귀)

여기까지는 UIBezierPath 객체를 생성하고 경로를 지정한 것일 뿐, 화면에는 드로잉이 일어나지 않는다. 

설정한 경로에 따른 드로잉을 표시하려면 드로잉의 성질을 설정한 뒤 테두리 그리기와 채워넣기 중 한가지를 선택해주어야지만 화면에 표시가 된다. 

UIColor.green.setFill()	//색상 채우기 설정
UIColor.red.setStroke()	//선 스트로크 색상 설정
path.linewidth = 3.0	//선 두께 설정
path.fill()	//채우기 시작 (앞에서 설정한 채우기 색상인 초록색으로 채워진다.)
path.stroke()	//선긋기 시작 (앞에서 설정한 스트로크 색상인 빨간색으로 채워진다.)

이와 같은 코드를 draw(rect:CGRect) 또는 draw()안에 구현을 하게 되면 ios에서 자동으로 호출하여 드로잉하게 된다. 이외에도 여러함수를 통해 다양한 드로잉이 가능하다.


UIColor

색상을 사용하는 방법은 UIColor 구조체를 사용하는 것이다. 

let green = UIColor.green

이 외에도 RGB값을 취하는 이니셜라이저나 , 색상,채도,밝기의 정보를 가지는 HSB 혹은 UIImage에서 이미지 패턴을 색상으로 취급하여 사용도 가능하다.

이러한 컬러들을 UIView에 있는 변수 backgroundColor: UIColor에 대입하면 뷰의 배경화면 색상이 변경이 된다.

 

색상에는 투명도인 alpha값을 조절하여 투명도를 조절 할 수 있다.

let semitransparentYellow = UIColor.yellow.withAlphaComponent(0.5)

투명(0.0) 부터 불투명(1.0)사이의 값을 적으면 된다.

투명도를 조절한 드로잉을 이용할 것이라면 꼭 알아할 점이 있는데, 바로 UIView에 있는 opaque: Bool변수이다. gpu 자원량을 아끼기 위해 기본적으로 true로 지정이 되어있으며 true의 경우 투명도 값을 사용하지 않는 것이다.(기존에 가려져서 표현 할 필요가 없는 부분이 투명해짐에 따라 표현을 해주어야 하기에..) 따라서 투명도를 이용한 드로잉을 할 때에는 opaque 변수의 값을 false로 설정하도록 하자.

인터페이스 빌더에서도 해당 옵션에 접근이 가능하다.

*opaque: 불투명한 안료. 필름에서, 화상의 일부나 필요한 부분에 칠하여 빛의 투과를 막음.

 

원한다면 뷰 자체를 투명하게 만들수도 있다. UIView에 alpha: CGFloat 변수에 값을 대입 하면 된다. 해당 뷰의 모든 드로잉이 해당 수치로 투명해지는 것이며, 마찬가지로 gpu 사용량이 올라가게 되니 참고하도록 하자.


Layers

UIView의 드로잉 매커니즘은 CALayer에서 이루어진다. *CA: CoreAnimation

뷰가 투명해지고, 움직이는 등 여러가지 애니메이션 효과는 CALayer에서 이루어진다고 보면 된다.

CALayer에는 여러가지 유용한 함수 및 변수들이 있는데, UIView의 Layer: CALayer변수에 접근하여 사용할 수 있다.

var cornerRadius: CGFloat	//모서리가 둥근 사각형
var borderWidth: CGFloat	//드로잉대상의 윤곽선
var borderColor: CGColor	//드로잉목표의 윤곽선 색상. UIColor가 아닌 CGColor다.

borderColor변수가 UIColor가 아닌 CGColor로 된 이유는 코어 애니메이션 레이어의 매커니즘이 UIKit 레이어의 코어 그래픽스 레이어 위에서 이루어지기 때문이다.

*CGColor: 코어그래픽스에서 사용되는 색상타입


View Transparency

뷰의 투명도에 대한 이야기다. 서브뷰를 가지고 있는 모든 뷰들은 서브뷰를 배열형태로 가지고 있는데, 사용자 입장에서 가장 멀리 있는 뷰가 배열의 첫번째 서브뷰가 된다. 즉 서브뷰가 스택형태로 쌓여 배열의 가장 마지막 서브뷰가 사용자에게 가장 가까운 뷰라고 보면 된다. 

물론 서브뷰 배열의 순서를 변경도 가능하다.  이런식으로 각 서브뷰의 투명도와 배치에 따라 사용자에게 보여지는 투명도를 조절할 수 있다.

 

이러한 뷰의 계층구조와 관계없이 뷰를 숨기는 것도 가능하다. 각 뷰에 isHidden: Bool변수를 통해서 true값이면 화면에서 사라지고, false값을 통해 화면에 보여지게 할 수 있다. 즉 계층구조 에는 남아있지만 화면에만 보여줄지 말지의 여부를 정하는 것이다. 주로 사용자와의 상호작용이 필요한 UI에 많이 사용한다. (Ex. 조건을 충족하면 숨겨 놓았던 뷰를 애니메이션과 함께 등장)


Drawing Text

텍스트 또한 드로잉이 가능하다. 우리가 흔히 사용하는 UILabel이 텍스트를 쉽게 드로잉 할 수 있는 뷰이다.

let text = NSAttributedString(string: "hello")	//텍스트생성
text.draw(at: ACGPoint)	//드로잉 실행
text.draw(in: CGRect)	//CGRect 기준 드로잉도 가능하다.
let textsize: CGSize = text.size	//텍스트크기 설정

Fonts

폰트는 앱UI의 전반적인 이미지를 결정짓는 중요한 요소이다. 앱이 ios의 look&feel을 전달하는데에 폰트가 큰 기여를 한다.

ios에서는 10가지 카테고리의 선호폰트(prefered font)를 제공한다. 제목폰트, 본문폰트, 캡션폰드, 각주폰트 등.. 상황과 목적에 맞는 선호폰트를 사용하도록 하자.  인터페이스 빌더에서 레이블, 버튼 등을 선택후 inspector 영역에서 선택이 가능하다. "prefered font"를 찾아 상황에 맞는 선호폰트를 적용하면 된다.

 

당연히 코드로 정의하는 것도 가능하다. UIFont에 정의된 preferedFont(forTextStyle: )을 사용하면 접근이 가능하다.

 

일부 사용자들은 본인이 사용하는 아이폰의 폰트크기를 기존 사이즈보다 크거나 때로는 작게 설정하여 사용한다. 이러한 사용자들을 고려하여 텍스트의 크기가 커지거나 작아졌을때에도 제대로 동작하는지 확인할 필요가 있는데, 해당 문제는 오토레이아웃을 사용하여 해결이 가능하다. 

 

let font - UIFont(name:"Helvetica", size:36.0)	//폰트명으로 폰트를 지정. 사이즈도 설정이 가능하다.

예시와 같이 폰트명을 직접 입력하여 폰트와 사이즈를 설정할 수 있다. 하지만 이렇게 UIFont생성자로 크기를 고정해 놓았다면, 휴대폰의 설정에서 폰트사이즈를 늘려도 반영되지 않게 된다. 특정 폰트를 지정하고, 휴대폰의 설정에 따라 크기를 반영하고싶다면 UIFontMetrics를 사용하여 해결이 가능하다. 

let font - UIFont(name:"Helvetica", size:36.0)	 //폰트명으로 폰트를 지정. 사이즈도 설정이 가능하다.
let metrics = UIFontMetrics(forTextStyle: .body) //.body외에 아무거나 입력해도된다. 객체생성이 목적이기 때문.
						 //UIFontMetircs.default도 가능.
let fontToUse = metrics.scaledFont(for: font)	 //사용자의 폰트크기를 반영하여 새로운 폰트로 제공된다.

 

System Fonts

데모앱을 만들때 사용했던 시스템 폰트가 있다. UI버튼에 글자를 적을 경우에 사용한다.

static func systemFont(ofSize: CGFloat) -> UIFont
static func boldSystemFont(ofSize: CGFloat) -> UIFont

시스템 폰트는 버튼에만 사용하도록 하고 그 외 유저에게 내용을 전달하는 텍스트에는 선호폰트를 사용해도록 하자.


Drawing Images

마지막으로 이미지를 드로잉 하는 방법이다. UILabel과 마찬가지로 이미지를 드로잉하려면 UIImageView를 이용할 수 있다. 하지만 해당 방법은 대상 뷰에 UIView를 서브뷰로 추가하는 개념이고, 이미지 객체를 드로잉하고 싶다면 UIImage 객체를 이용할 수 있다.

 

UIImage객체는 jpg, gif등 여러가지 이미지 포맷을 지원한다. 그렇다면 어떻게 이미지를 가져올까? 총 3가지 방법이 있다.

 

1. 이름으로 가져오기

let image: UIImage? = UIImage(named: "foo")

Assects.xcassect 속의 파일을 이름으로 탐색하여 이미지를 가져온다. 당연히 사용할 이미지를 미리 드래그하여 저장해두어야 한다. 해당이름의 파일이 없을수도 있으니 UIImage? 옵셔널 타입을 사용하게 된다. (이미지가 없으면 nil을 반환하게 된다.)

 

2. 파일시스템을 이용하여 가져오기

let image: UIImage? = UIImage(contentsOfFile: pathString)

이미지의 파일 주소를 입력하여 가져올 수 있다. 마찬가지로 파일이 없을 수도 있으니 UIImage? 옵셔널로 접근해야 안전하다.

 

3. 인터넷을 통해 가져오기

let image: UIImage? = UIImage(data:aData)

인터넷을 통해 이미지를 받아올 수 도 있다. aData 속에 이미지 정보가 담겨있고, UIImage는 이를 들여다 보고서 이미지파일일 경우에 해당 이미지를 가져오게 된다. 

 

위 세가지 방법을 통해 이미지를 객체로 담아 드로잉을 시작하면 된다. 

image.draw(at point: ACGPoint)	//좌표를 통해 드로잉
image.draw(in rect: ACGRect)	//rect를 통해 드로잉 이미지가 rect의 크기에 맞게 스케일된다.
image.drawAsPattern(in rect: ACGRect)	//rect의 크기에 맞게 이미지를 패턴으로 드로잉한다.

 

redraw on  bounds change?

뷰의 bounds가 바뀌게 될 경우에는 다시 드로잉을 해야한다. 화면이 회전되는 경우가 이에 해당하는 예시인데, ios는 해당 경우에 자동으로 드로잉을 다시 해주지 않는다. 그저 화면비율에 맞게 뷰들을 늘려버리게 되는데 우리는 이러한 문제를 해결하기 위해 드로잉을 다시 해주어 배치를 알맞게 바꾸어 주어야 한다. 앞서 이야기 해듯이 우리는 draw()함수를 직접 호출할 수 없다.

 

이럴때 사용하는 것이 UIView에 있는 contentMode: UIViewContentMode 변수이다.

var contentmode: UIViewContentMode

해당 변수의 값에는 여러가지가 있지만 3가지 카테고리로 분류된다.

1. 비트유지

크기를 재설정 하지 않고 비트만 이동한다. 즉 서브뷰들을 위치이동만 시키는것.

.left / .right / .top / .bottom / .topRight / .topLeft / .bottomRight / .bottomLeft / .center

 

2. 뷰에 맞게 비트 스케일 (기본값)

.scaleToFill / .scaleAspectFill / .sacaleAspectFit

비트를 늘리거나 줄여서 새로운 bounds에 맞춘다. scaleToFill이 기본값으로 되어있다. 

 

3. 다시 드로잉

.redraw

뷰의 bounds가 변경될 경우 darw(in rect: )를 재호출하여 드로잉을 다시하게 된다. 가장 이상적인 방법이다.

 

그렇다면 뷰의 bounds 변경시 서브뷰에는 어떤 조치를 해주어야 할까? 레이아웃 구성시 Auto Layout Constraints 설정을 해두었다면 자동으로 이루어 지지만, layoutSubViews()함수를 오버라이딩하여 수동으로 레이아웃을 정해줄수도있다.  

override func layoutSubviews(){
	super.layoutSubViews()
	//새로 변경된 bounds에 알맞는 서브뷰의 frame을 설정해야 한다.
}

demo

enum을 활용한 PlayingCard데모를 작성했다. 

 

PlayingCard.swift

import Foundation
 
struct PlayingCard:CustomStringConvertible{
    //CustomStringConvertible 프로토콜의 description 변수 구현
    //해당 구조체 출력시 반환 값을 수정할 수 있다.
    var description: String{
        return "\(rank) \(suit)"	//rank와 suit값을 출력한다.
    }
    
    var suit:Suit
    var rank:Rank
    
    enum Suit:String,CustomStringConvertible{
    	//Suit형 변수 출력시 반환될 문자열 구현
        var description: String{
            return rawValue		//enum의 rawValue를 반환한다.
        }
        
        //각 case의 rawValue를 String형으로 지정했다. 기본값은 Int이다.
        case spades = "♠️"	
        case hearts = "❤️"
        case clubs = "♣️"
        case diamonds = "♦️"
        
        //모든 suit를 반환하는 all 변수
        static var all = [Suit.spades, .hearts, .clubs, .diamonds]
    }
    
    enum Rank:CustomStringConvertible{
    	//Rank형 변수 출력시 반환될 문자열 구현
        var description: String{
            return String(order)
        }
        
        case ace
        case face(String)
        case numeric(Int)
        
        //case에 해당되는 order변수의 값 지정
        var order:Int {
            switch self{
            case .ace: return 1
            case .numeric(let pips): return pips
            case .face(let kind) where kind == "J":return 11
            case .face(let kind) where kind == "Q":return 12
            case .face(let kind) where kind == "K":return 13
            default : return 0
            }
        }
        
        //Suit와 마찬가지로 모든 Rank값을 반환하는 all변수
        static var all: [Rank]{
            var allRanks = [Rank.ace]
            for pips in 2...10{
                allRanks.append(Rank.numeric(pips))
            }
            allRanks += [Rank.face("J"),face("Q"),face("K")]
            return allRanks
        }
    }
}

이번데모에서 새로 배운건 CustomStringConvertible 프로토콜이다. 해당 프로토콜을 사용하는 구조체/클래스는 description변수를 구현하여 해당 구조체/클래스 출력시의 문자열을 수정할 수 있다.

 

다음으로는 enumrawValue이다. 기본적으로 enum은 각 case에 해당하는 값에 rawValue가 Int형으로 0부터 부여되는데, 선언부에 자료형을 따로 선언하게 되면 다른 자료형의 rawValue를 지정할 수 있다. Suit형 선언부를 보면 String으로 자료형이 지정되어있는 것을 확인할 수 있다.

 

PlayingCardDeck.swift

import Foundation

struct PlayingCardDeck{
    private(set) var cards = [PlayingCard]()	//빈 PlayingCard 배열 생성
    
    init(){
        for suit in PlayingCard.Suit.all{
            for rank in PlayingCard.Rank.all{
                cards.append(PlayingCard(suit: suit, rank: rank))	//모든 종류의 카드 추가
            }
        }
    }
    
    //카드반환 함수
    mutating func draw() -> PlayingCard?{
        if cards.count > 0 {
            return cards.remove(at: cards.count.arc4random)	//카드더미에서 무작위로 카드 반환
        }else{
            return nil	//카드더미에 카드가 남아있지 않다면 nil 반환
        }
    }
}

//arc4random함수 Int에 extension
extension Int{
    var arc4random:Int{
        if self > 0{
            return Int(arc4random_uniform(UInt32(self)))
        }else if self < 0{
            return -Int(arc4random_uniform(UInt32(abs(self))))
        }else{
            return 0
        }
    }
}

카드 더미에서 무작위 카드를 뽑아내기 위해 지난번 concentration 데모에서 사용했던 arc4random을 다시 사용했다. 

 

ViewController.swift

import UIKit

class ViewController: UIViewController {

    var deck = PlayingCardDeck()
    override func viewDidLoad() {
        super.viewDidLoad()
        for _ in 1...10{
            if let card = deck.draw(){
                print("\(card)")
            }
        }
    }
}

완성된 모델이 정확히 동작하는지 테스트 하기 위한 코드를 적었다. 

 

출력값

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

[Lecture 1] Getting Started with SwiftUI - 1  (0) 2023.06.16
day06_multiTouch  (0) 2022.03.26
day04_swift_part2  (0) 2021.10.25
day03_swift_part1  (0) 2021.10.12
day02_MVC  (0) 2021.10.03

mutating

구조체의 객체를 변경하는 함수에는 함수의 선언시 앞에 mutating을 붙여야한다. 

왜 구조체를 써야하나요?

클래스와 구조체의 큰 차이점은 바로 클래스는 참조타입, 구조체는 값타입이라는 것이다. 클래스 객체는 힙 메모리 내부에 존재하며, 클래스 객체를 전달할 경우(함수나 변수에 전달 시), 객체를 가리키는 포인터가 전달되는 것이다. 이런 식으로 전달이 여러번 이루어 지다 보면 무수히 많은 포인터가 존재하게 되고 만다. 

 

하지만 구조체 객체의 경우 힙 메모리에 존재하지 않고, 구조체를 전달하게 될 때, 구조체 객체가 계속해서 복사된다. 스위프트의 경우, 객체의 내용이 변경되었을 때에만 복사가 이루어지므로 경우에 따라 클래스보다 구조체를 이용하는 것이 메모리 관리에 더욱 효율적일수가 있다. 이렇게 객체의 내용에 변경이 생길 시, 스위프트에게 알려야하는데 그러한 알림이 바로 mutating 키워드다.

 

따라서 예제의 Concentration 클래스를 구조체로 변경하기 위해서 func chooseCard(at index: Int)에 mutating키워드를 적어주었다. 카드배열을 순회후 카드의 앞면, 뒷면 여부가 변경되기 때문이다.


protocol

별도의 구현이 없는 함수와 변수의 리스트이다. 별도의 저장공간이 없으며 변수와 함수의 선언만 작성되어있다. 따라서 프로토콜을 상속받은 뒤 상속받은 곳에서 구현을 해야하는데, 유의할 점은 프로토콜에 선언된 모든 변수와 함수를 구현해야 한다는 점이다.

*오브젝티브-C에서의 프로토콜은 선택적으로 구현을 할 수 있으므로 차이점을 잘 알아두어야 한다. 

 

선언은 아래와 같이 구현은 하지 않은채로 작성하면 된다.

protocol someProtocol : inheritancedProtocol1, inheritencedProtocol2 {
	var someProperty : Int{get, set}	//읽기 쓰기가 가능한 변수
	func aMethod (...) -> someType		//함수 
	mutating func changeIt()		//객체변경 함수
	init(arg:Type)				//초기화
 }

프로토콜에는 저장공간이 할당되어있지 않으므로 선언만 가능하며, 여러개의 프로토콜을 동시에 상속받을수도 있고, 프로토콜이 다른 프로토콜을 상속받는 것 또한 가능하다. 당연히 마지막에는 상속받은 모든 프로토콜의 사항들을 구현해주어야 한다. 

 

사용방법

struct someStruct : someProtocol, otherProtocol {
	//someStruct 구조체의 내용을 작성.
	//물론 someProtocol, otherProtocol의 모든 변수와 함수의 내용을 구현해야한다.
}
extension something : someProtocol {
	//something에 추가할 someProtocol의 모든 변수와 함수를 구현.
}

상속 및 확장이 가능하며 프로토콜의 모든 함수와 변수의 구현을 해주면 된다.

 

objective - C

@objc protocol UIScrollViewDelegate {
	@available(iOS 2.0, *)
    optional func scrollViewDidScroll(_ scrollView: UIScrollView)

    @available(iOS 3.2, *)
    optional func scrollViewDidZoom(_ scrollView: UIScrollView)
    ...
}

objective-C(objc)로 작성된 프로토콜을 들여다 보면 optional이라는 키워드를 볼 수 있다. 앞서 말한 objc로 작성된 프로토콜은 선택적 구현이 가능하다 했는데, 바로 저 optional이 선택적 구현이 가능한 함수이다.


String

문자열(String)은 문자(Character)의 배열이다. 문자열을 다룰때 주의해야 할 점은 스위프트의 문자열은 유니코드방식으로 구성되어있다보니 정수형태의 index를 사용할 수 없다. 스위프트에서 문자열을 다루려면 String.index를 다루어야 한다. 다행히 String은 collection이라는 프로토콜을 상속받았고, String에는 index를 다루기 위한 편리한 collection의 메소드들이 구현되어있다.

//문자열 생성
let str:String = "apple computer"
//첫번째 String.index반환
let firstIndex = str.startIndex
//첫번째 인덱스로부터 4번째 즉 5번째 String.index반환
let fifthIndex = str.index(firstIndex, offsetBy: 4)
//다섯번째 문자 반환
let fifthCharacter = str[fifthIndex]

//출력결과 Index(_rawBits: 262401)
print(fifthIndex)
//출력결과 e
print(fifthCharacter)


//nil을 반환할 수 있기에 if let을 사용한다.
if let firstSpaceIndex = str.firstIndex(of: " "){
    //공백 다음 첫번째 인덱스 저장
    let firstIndexAfterSpace = str.index(firstSpaceIndex, offsetBy: 1)
    let firstCharacterAfterSpace = str[firstIndexAfterSpace]
    //출력결과 c
    print(firstCharacterAfterSpace)
    
    //공백이후 두번째 문자열 저장
    let secondWord = str[firstIndexAfterSpace..<str.endIndex]
    //출력결과 computer
    print(secondWord)
}

String.index를 출력해보면 정수형태가 아닌 것을 알 수 있다. 예시와 같이 메소드를 활용해 문자열을 배열로서 활용해보도록 하자.

 

카드뒤집기 예제에서도 문자열을 활용해보았다.

    //이모티콘 후보들이다. 배열로 담겨있었으나 문자열로 변경하였다.
    var emojiChoices = "🦇😱🙀👿🎃👻🍭🍬🍎"
    
    //emojiChoices에서 이모지를 뽑아 [카드의 id: 이모지] 쌍으로 딕셔너리를 생성한다.
    var emoji = [Int:String]()
    
    //카드에 이모지를 그려넣는 함수. 카드를 매개변수로 받는다. 이모지를 반환한다.
    func emoji(for card: Card) -> String{
        if emoji[card.identifier] == nil, emojiChoices.count > 0{
            //만약 매개변수 카드의 id가 emoji딕셔너리의 키로 존재하지 않는다면, 
            //이모지 후보에서 랜덤하게 뽑아온뒤 [카드id:이모지] 딕셔너리에 추가한다.
            let randomStringIndex = emojiChoices.index(emojiChoices.startIndex,
            offsetBy: emojiChoices.count.arc4random)
            emoji[card.identifier] = String(emojiChoices.remove(at: randomStringIndex))
        }
        
        //카드 id를 키값으로 이모지 반환
        return emoji[card.identifier] ?? "?"
    }

NSAttributedString

문자열의 모든 문자가 각각 딕셔너리를 가지는 문자열이다. 딕셔너리에는 폰트, 색상 등 여러가지가 될수 있다. 즉 각각의 문자가 속성을 가진 문자열인 셈이다. 

//속성 생성 및 정의
let attributes:[NSAttributedString.Key:Any] = [
	.strokeColor:UIColor.orange,
	.strokeWidth:5.0
]
//속성문자열 생성
let attributedString = NSAttributedString(string: "flipCount: \(flipCount)", attributes: attributes)
//문자열 활용
flipCountLabel.attributedText = attributedString

*NS가 붙은 API들은 대부분 오래된 objc클래스이다.

주의할 점은 NSAttributedString은 수정할 수 없다. 따라서 문자열을 수정하고 싶다면 setAttributedString()메소드가 담겨있는 NSMutableAttributedString()으로 생성해야 한다는 점을 알아두자.

 

속성문자열은 카드뒤집기 예제의 flipCount에 적용해보았다.

    //flipCount 글자를 속성문자열로 수정하는 함수.
    private func updateFlipCount(){
        let attributes:[NSAttributedString.Key:Any] = [
            .strokeColor:UIColor.orange,
            .strokeWidth:5.0
        ]
        let attributedString = NSAttributedString(string: "flipCount: \(flipCount)",
        attributes: attributes)
        flipCountLabel.attributedText = attributedString
    }
    
    //카드를 뒤집은 수를 나타내는 레이블.
    @IBOutlet weak var flipCountLabel: UILabel!{
    	//didset을 통해 값의 변경이 일어날 때 마다 updateFlipcount()가 실행된다.
        didSet{
            updateFlipCount()
        }
    }
    
    //flipCountLabel에 표시할 변수. 당연하게도 0부터 시작한다.
    var flipCount = 0 {
        //변수의 값 변동이 일어날 때마다 수행되는 메소드. flipCountLabel에 변경된 값을 전달하고 표시한다.
        didSet{
            updateFlipCount()
        }
    }

FunctionTypes

함수를 하나의 타입으로 선언한다. 

//함수 타입 선언 Double을 입력받고 Double을 반환한다.
var operation: (Double) -> Double
//sqrt메소드 입력(제곱근 반환)
operation = sqrt

//출력결과 2.0
print(operation(4.0))

sqrt메소드와 달리 직접 함수를 작성해서 대입하는 것도 가능하다.

//함수 타입선언
var operation: (Double) -> Double

//함수생성
func changeSign(operand: Double) -> Double{
    return -operand
}

//생성한 함수 대입
operation = changeSign(operand:)
//출력결과 -4.0
print(operation(4.0))

하지만 함수를 새로 생성하는 점은 코드의 라인이 길어지는 단점이 생긴다.


closure

이전 예시에서 더 짧게 코드를 줄이는 방법이 클로저를 활용하는 방법이다. 클로저는 인라인 함수로 인자에 함수를 적어서 넣을수 있다.

var operation: (Double) -> Double

//{}중괄호 안에 함수의 내용을 적으면 되며 반환값은 "in return" 키워드를 사용한다.
operation = {(operand:Double) -> Double in return -operand}

operation = changeSign(operand:)
print(operation(4.0))

여기서 더 강력한 기능은 스위프트의 타입추론이다. 위에 정의한 operation에서 이미 매개인자와 출력인자의 타입이 정의되어있으므로 클로저에서는 타입생략이 가능한 것이다.

//입력인자와 출력인자에 대한 타입과 갯수가 정의되어있다.
var operation: (Double) -> Double

//매개인자들의 타입 생략 가능.
operation = {(operand) in return -operand}

//출력결과 -4.0
print(operation(4.0))

이어서 스위프트는 operation에는 리턴값이 존재한다는 것 또한 인지하고 있다. 즉 return이라는 키워드를 생략할 수 있게 되는데, 

//입력인자와 출력인자에 대한 타입과 갯수가 정의되어있다.
var operation: (Double) -> Double

//매개인자들의 타입 생략 가능.
operation = {(operand) in -operand}

//출력결과 -4.0
print(operation(4.0))

이런식으로 in이라는 키워드만 적으면 된다. 클로저에는 더 강력한 기능이 있는데, 바로 매개인자의 치환이 가능하다는 것이다. 첫번째 매개인자는 $0, 두번째 매개인자가 있다면 $1, 이런식으로 $0, $1, $2 ... 을 통해 매개인자를 대치할 수 있게 되는데, 그렇게 되어 최종적으로 다음 예시처럼 완성이 된다.

//입력인자와 출력인자에 대한 타입과 갯수가 정의되어있다.
var operation: (Double) -> Double

//매개인자의 부호변경
operation = {-$0}

//출력결과 -4.0
print(operation(4.0))

map()

배열에는 map()이라는 메소드가 존재한다. 하나의 인수를 받고 새로운 배열로 반환을 하는데, 이때 인자는 함수를 받는다. 즉 배열의 요소를 하나씩 뽑아서 인자로 받은 함수에 입력한뒤 결과값을 배열로 반환하는 메소드이다. 앞서 설명한 클로저와 항상 쓰이는 메소드이다.

let primes = [2.0,3.0,4.0,5.0]
let negativePrimes = primes.map({-$0})

//출력결과 [-2.0, -3.0, -4.0, -5.0]
print(negativePrimes)

여기서 TrailingClosure라는 문법이 있는데, 어떠한 함수라도 마지막 인자가 클로저라면 클로저를 괄호밖으로 내놓고 작성하는 문법이다. 

인자가 유일하다면 메소드의 괄호를 생략도 가능한데, 이러한 문법들은 모두 인자로 받는 함수가 길어지게 되었을 때의 가독성을 위한 문법이다. 편한 방식을 선택해서 작성하면 된다.

let primes = [2.0,3.0,4.0,5.0]

let negativePrimes = primes.map({-$0})
//출력결과 [-2.0, -3.0, -4.0, -5.0]
print(negativePrimes)

//클로저를 괄호 밖에서 작성해도 된다.
let primes2 = primes.map() {Int($0)}
//출력결과 [2, 3, 4, 5]
print(primes2)

//인자가 유일하다면, 괄호를 생략가능하다.
let primes3 = primes.map {1.0/$0}
//출력결과 [0.5, 0.3333333333333333, 0.25, 0.2]
print(primes3)

filter()

배열이 상속받고 있는 collection 프로토콜의 메소드이다. 하나의 함수를 인자로 받고, Bool을 반환한다. true를 반환하는 요소만 배열에 추가하여 사용한다.

let arr = [1,2,3,4,5,6,7,8,9,10]
//짝수만 새로운 배열에 추가
let evenArr = arr.filter(){$0 % 2 == 0}

//출력결과 [2,4,6,8,10]
print(evenArr)

 

카드뒤집기 예제에서는 앞면인 카드를 확인하는 연산변수(computedProperty)에 적용해보았다. 주석처리된부분이 filter를 통해 삭제된 라인들이다. 상당수의 라인이 제거된다.

//유일하게 앞면 상태의 카드를 나타내는 속성 이후에 두번째로 오픈되는 카드와 id를 비교하기위해 사용된다.
    var indexOfOneAndOnlyFaceupCard:Int? {
        //indexOfOneAndOnlyFaceupCard 호출시
        get{
            //카드 배열 속 앞면인 카드들을 모두 반환. 
            //만약 앞면인 카드가 단 한장이라면 해당 카드인덱스 반환. 한장보다 많다면 nil 반환
            return cards.indices.filter{cards[$0].isFaceUp == true}.oneAndOnly

//            //리턴을 위한 임시 변수 생성. optional로 생성한다.
//            var foundIndex:Int?
//
//            //카드배열을 돌면서 앞면상태인 카드를 탐색한다.
//            for index in cards.indices{
//                if cards[index].isFaceUp{
//                    //앞면인 카드발견 + foundIndex가 nil, 즉 앞면인 카드가 오로지 한장이라면,
//                    if foundIndex == nil {
//                        //앞면이 유일한 카드는 해당 카드가 된다.
//                        foundIndex = index
//                    } else{
//                        //foundIndex에 값이 있는경우, 즉 앞면인 카드가 한장보다 많다면, 유일하게 앞면인 상태가 아니므로 nil을 반환한다.
//                        return nil
//                    }
//                }
//            }
//            //foundIndex의 값으로 indexOfOneAndOnlyFaceupCard설정
//            return foundIndex
        }
        //indexOfOneAndOnlyFaceupCard 대입시
        set{
            //카드 배열을 돌면서 카드의 앞면, 뒷면을 설정한다.
            for index in cards.indices{
                //사용자가 선택한 카드번호(nexValue)가 아닌 나머지 카드들은 모두 뒷면으로 설정한다.
                cards[index].isFaceUp = (index == newValue)
            }
        }
    }
    
extension Collection{
    var oneAndOnly:Element?{
    	원소가 한개라면? 첫번째를(하나뿐인 원소를) 반환해라. 두개이상이라면 nil을 반환한다.
        return count == 1 ? first : nil
    }
}

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

day06_multiTouch  (0) 2022.03.26
day05_view  (0) 2021.12.30
day03_swift_part1  (0) 2021.10.12
day02_MVC  (0) 2021.10.03
day01_ios  (0) 2021.09.28

computedProperty

스위프트에서 변수는 property라고도 불리운다. 즉 연산용 변수라고 보면 된다. 

var foo: Double{
     get{
          //return calculater value
     }
     set(newValue){
          //foo has changed to newValue
     }
}

get에 해당하는 영역에는 변수 foo의 값을 불러올 때 사용되는 코드를, set에는 변수 foo에 새로운 값을 대입할 때 동작할 코드를 적어주면 된다. newValue는 대입할 경우에 들어올 새로운 값을 의미한다. 당연히 괄호안의 이름을 바꾸면 해당 이름의 변수로 사용이 가능하다. 생략이 가능하며 생략시에는 무조건 newValue로 사용해야한다.

var temp = 1
var x:Int {
    //x를 호출할 경우 temp를 반환
    get{
        return temp
    }
    
    //x에 대입할 경우 두배의 값을 temp에 대입
    set(newValue){
        temp = newValue * 2
    }
}

//x에 10을 대입(set) -> temp에 20이 저장된다.
x = 10

//x호출(get) -> temp의 값인 20이 출력된다.
print(x)

예제로 입력 값의 두배를 불러오는 computedProperty를 작성해보았다. 주의할 점은 return을 위한 임시변수가 필요하다는 점이다. 즉 x는 저장을 위한 변수가 아닌 말그대로 연산을 위한 변수인 것이다. 

 

아래 예제는 지난번 데모에 computedProperty를 적용한 부분이다. 

    //유일하게 앞면 상태의 카드를 나타내는 속성 이후에 두번째로 오픈되는 카드와 id를 비교하기위해 사용된다.
    var indexOfOneAndOnlyFaceupCard:Int? {
        //indexOfOneAndOnlyFaceupCard 호출시
        get{
            //리턴을 위한 임시 변수 생성. optional로 생성한다.
            var foundIndex:Int?
            
            //카드배열을 돌면서 앞면상태인 카드를 탐색한다.
            for index in cards.indices{
                if cards[index].isFaceUp{
                    //앞면인 카드발견 + foundIndex가 nil, 즉 앞면인 카드가 오로지 한장이라면,
                    if foundIndex == nil {
                        //앞면이 유일한 카드는 해당 카드가 된다.
                        foundIndex = index
                    } else{
                        //foundIndex에 값이 있는경우, 즉 앞면인 카드가 한장보다 많다면, 유일하게 앞면인 상태가 아니므로 nil을 반환한다.
                        return nil
                    }
                }
            }
            //foundIndex의 값으로 indexOfOneAndOnlyFaceupCard설정
            return foundIndex
        }
        //indexOfOneAndOnlyFaceupCard 대입시
        set{
            //카드 배열을 돌면서 카드의 앞면, 뒷면을 설정한다.
            for index in cards.indices{
                //사용자가 선택한 카드번호(nexValue)가 아닌 나머지 카드들은 모두 뒷면으로 설정한다.
                cards[index].isFaceUp = (index == newValue)
            }
        }
    }

Model에 해당하는 Concentration 클래스의 indexOfOneAndOnlyFaceupCard 변수이다. 해당 작업으로 인해 호출부분의 코드가 조금 더 간결해졌다.

추가로 computedProperty는 get만 설정하는것이 가능하다. 

//게임 생성. 카드 쌍의 수는 카드버튼 갯수의 절반이다.
lazy var game = Concentration(numberOfPairsOfCards: numberOfPairsOfCards)
    
var numberOfPairsOfCards: Int{
    //numberOfPairsOfCards 호출시 cardButtons배열 길이의 절반 반환
    return cardButtons.count+1/2
}

get만 설정할 경우 예제와 같이 return만 적는 것이 가능하다. 하지만 반대로 set만 설정하는 것은 불가능하다.


accessControl

접근제어. 아직 작성중인 클래스에 접근 가능역역을 표시하는 작업이다.

internal(default) - 같은 앱 내에서 어떤 객체든 접근 가능한 상태이다 .기본값이라 생략이 되어있다. 앱이나 프레이워크의 모든 요소가 접근이 가능한 상태이다.

private - 다른 앱에서 접근 불가능한 상태이다. 

private(set) - 변수를 위한 옵션이다. 다은 사람이 값에 접근이 가능하지만 설정은 할 수 없다. (읽기전용)

fileprivate - 해당 파일 안에 있는 어떠 것이든 서로 접근가능한 상태.

 

프레임 워크에만 사용하는 접근제어

public - 외부에서 접근 가능

open - 외부에서 접근 가능 + 서브클래스 생성 등 여러가지 수정 및 추가가 가능

 

*공유할 일이 없으면 비공개처리를 해두는 것이 좋다. 

아울렛 변수의 경우 공유할 경우가 없으니 모두 비공개로 하자.


extension

클래스에 함수 및 속성(변수)의 추가

extension Int{
    //arc4Random함수 추가. 기존의 UInt32의 자료형을 출력하는 함수를 Int형으로 반환하게 수정하였다.
    func arc4Random() -> Int{
        assert(self > 0, "it has to be more than 0")
        return Int(arc4random_uniform(UInt32(self)))
    }
}

assert(condition:Bool, message:String)함수는 조건식을 체크하고, 조건식이 맞지 않으면(false), 오류메세지(String)를 출력한다.

Int 클래스에 arc4random()함수를 추가하였으며, 해당 함수는 0보다 큰 정수만 입력받으므로 assert()함수로 조건을 체크한다. 

이렇게 추가를 하게되면 기존의 지저분하게 보이던 코드를 정리할 수 있다.

extension Int{
    var arc4random: Int{
        if self > 0{
            //0보다 큰 정수라면, 0과 정수사이의 랜덤 숫자 반환
            return Int(arc4random_uniform(UInt32(self)))
        } else if self < 0 {
            //0보다 작은 정수라면, 0과 음수사이의 랜덤 숫자 반환
            return -Int(arc4random_uniform(UInt32(abs(self))))
        } else {
            //0이라면, 0반환
            return 0
        }
    }
}

해당 함수는 변수로도 설정이 가능하며, arc4random()함수의 경우 0보다 큰 정수만 입력받는것을 보완하여 추가하였다.

아래의 코드처럼 호출부가 더욱 간결해졌다.

//기존의 두줄이었던 코드
let randomIndex = Int(arc4random_uniform(UInt32(emojiChoices.count)))
emoji[card.identifier] = emojiChoices.remove(at: randomIndex)

//Int클래스에 arc4random을 추가한 후
emoji[card.identifier] = emojiChoices.remove(at: emojiChoices.count.arc4random)

enum

열거형 데이터타입이다. 가질 수 있는 값들을 미리 지정해서 사용하는 것이 특징이다.

enum fastFoodMenuItem{
    case hamburger
    case fries
    case drink
    case cookies
}

//fastFoodMenuItem이라는 자료형을 사용할 수 있다.
var burger:fastFoodMenuItem = .hamburger

if burger == .hamburger{
    print("it is hamburger")
}

사진처럼 지정된 값들만 입력이 가능하며 좌변에 fastFoodMenuItem이라 명시를 해줘서 우변에는 타입추론이 가능하다.

타입추론을 지원하지 않는다면 var burger:fastFoodMenuItem = fastFoodMenuItem.hamburger 라고 적어야 했다.

enum fastFoodMenuItem{
    case hamburger
    case fries
    case drink
    case cookie
    
    func showMenu() {
        switch self{
        case .hamburger : print("hamburger")
        case .fries : print("fries")
        case .cookie : print("cookie")
        case .drink : print("drink")
        }
    }
}

//메뉴 생성
var menu0: fastFoodMenuItem = .hamburger

//메뉴 출력
menu0.showMenu()

함수 또한 설정이 가능하며 예시와 같이 해당 메뉴가 무엇인지 출력하는 코드를 작성해보았다.

enum fastFoodMenuItem{
	//연동자료 선언
    case hamburger(numberOfPatties: Int)
    //당연히 연동자료를 enum으로 선언이 가능하다.
    case fries(size: fryNumberOfSize)
    case drink(String, ounces:Int)
    case cookie
    
    func showMenu() {
        switch self{
        //연동자료를 사용하기 위해서는 let을 이용하면 된다. 정해진 연동자료보다 많이 선언하면 오류가 출력된다.
        case .hamburger(let pattyCount) : print("hamburger with \(pattyCount) patties.")
        case .fries(let size) : print("fries \(size) size.")
        case .cookie : print("cookie.")
        case .drink(let brand, let ounces) : print("drink. a \(ounces)oz \(brand)")
        }
    }
}

enum fryNumberOfSize{
    case large
    case small
}

var menu0: fastFoodMenuItem = .hamburger(numberOfPatties: 2)
var menu1: fastFoodMenuItem = .fries(size: .large)
var menu2 = fastFoodMenuItem.drink("sprite", ounces: 13)

menu0.showMenu()
menu1.showMenu()
menu2.showMenu()

mutating func changeTo(menu:fastFoodMenuItem){
        self = menu
    }

self를 사용하여 값의 변경이 가능하다. 유의할 사항은 mutating인데, 간단히 말하면 값의 변경이 일어난 다는 것을 스위프트에게 알리는 문구라고 한다.

var menu0: fastFoodMenuItem = .hamburger(numberOfPatties: 2)
menu0.showMenu()

menu0.changeTo(menu: .fries(size: .small))
menu0.showMenu()

해당 함수 호출로 메뉴를 변경하였다.


optional

스위프트를 접하고 가장 궁금했던 부분이 옵셔널이다. 이 옵셔널이 결국 enum이다. 

enum optional<T>{ //a generic type like Array<Element>, Dictionary<Key,Value>
    case none
    case some(<T>)
}

<T>의 의미는 어떤 형태든 들어올 수 있다는 얘기다. 

값이 존재하지 않으면 .none, 어떠한 값이 존재하면 .some()을 통해서 switch을 통해 우리가 알고있는 옵셔널로 동작하게 되는것이다. 

상수 y를 보면 x, foo(), bar, z가 모두 옵셔널의 형태로 연결되어 하나라도 nil이라면, nil을 반환하게 되는 구조이다. 이러한 구조를 옵셔널 체인이라고 부른다.


automaticReferenceCounting

자바는 가비지 컬렉션을 통해 힙메모리를 관리한다. 반면 스위프트는 가비지 컬렉션이 아닌 자동참조횟수정책을 이용하여 힙메모리를 관리한다. 힙내의 참조타입에 포인터를 만들때마다 스위프트는 어딘가에 있는 카운터에 1을 추가. 해당 포인터가 가리키는것이 없어지거나, 더이상 가리키지 않게 되었을 때 ,카운트를 줄이면서 0이 되면 힙메모리에서 해당하는 참조타입을 제거하게 된다. 

influencing ARC

참조횟수계산에 영향을 미치는 3가지 방식의 포인터가 있다. 

strong

일반적인 참조횟수계산방식이다. 포인터가 무언가를 가리키는 한 힙내에 계속 놔둔다.

weak

힙내의 어떤것을 가리키지만 가리키는 대상이 strong 포인터를 가지고 있으면 힙내에 유지, 더이상 strong 포인터를 가지고 있지 않게되면 해당 포인터에는 nil을 반환하고 가리키는 대상은 힙메모리에서 제거한다. (ex. optional, oulet)

unowned

참조횟수계산에 참여하지 않는다. 힙내부의 어떤 것을 가리키고 있을 때, strong포인터로서 인식하지 않는다. 가리키고 있는 대상이 힙내에서 사라졌을때, 해당 포인터에 접근하지 않는다. → 메모리 사이클을 피하기 위해 사용한다.

*메모리사이클: 힙 메모리내에서 서로를 참조하여 힙 메모리내에 계속해서 남게되는 일

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

day06_multiTouch  (0) 2022.03.26
day05_view  (0) 2021.12.30
day04_swift_part2  (0) 2021.10.25
day02_MVC  (0) 2021.10.03
day01_ios  (0) 2021.09.28

코드는 유지보수 및 수정이 용이하도록 작성해야 하는데, MVC 아키텍쳐는 그러한 여러가지 방법들 중 하나이다.


What is MVC?

Model, View, Controller의 약자이다. 즉 하나의 프로젝트를 크게 모델, 뷰, 컨트롤러로 나누어서 작업하는 것을 의미한다. 

 

Model - what your app is

 

View - contoller's minons

 

Controller - how model is presented

 

모델에는 해당 앱이 무엇인지를, 컨트롤러는 해당 모델이 어떻게 보여지는지를 기술하는 것이다. 그렇다면 뷰는? 컨트롤러가 지시하는대로 유저에게 모습을 보여주는 부분이라고 생각하면 된다. 

전반적인 통신은 모델과 컨트롤러, 그리고 컨트롤러와 뷰 이렇게 통신을 하게된다. 즉 모델과 뷰는 직접적으로 통신을 할 수 없다는 얘기. 

 

모델은 자신에게 변경사항이 일어나면 컨트롤러에게 신호를 보낸다. 그렇게 되면 컨트롤러는 모델을 재탐색하여 변경사항을 알아내고 이를 뷰에 반영하게 된다. 해당 통신을 ios 에서는 KVO(key-value observing)이라고 한다. 

 

뷰는 사용자의 이벤트를 감지하고 이를 action을 통해 컨트롤러에게 알린다. 반대로 컨트롤러는 outlet을 통해 자신의 지시를 전달하게 된다. MVC모델은 컨트롤러(controller)가 무엇을(model) 어떻게 표현(view)할것인지 구분하여 코드를 작성하는 방법이라고 생각하면 된다.

 

 그렇다면 화면이 여러개인 앱들이 있을텐데 그러한 앱들은 MVC를 어떻게 적용하느냐? 라는 질문이 생기게 된다. 이 경우, 여러개의 MVC가 만들어 지는데 각 MVC는 나머지 MVC들을 하나의 뷰로 여기게 된다.


Demo

해당 데모는 지난번 카드뒤집기 데모에 MVC를 적용하여 카드 짝맞추기 게임으로 만든 것이다. 

Model

import Foundation

struct Card{
    //카드가 앞면인지 나타내는 속성
    var isFaceUp = false
    //카드의 짝이 맞는지 나타내는 속성
    var isMatchedUp = false
    //카드의 짝을 확인하기 위한 속성
    var identifier: Int
    
    //정적 선언. 즉 card타입 자체에 부여되는 속성이다.
    static var identifierFactory = 0
    
    //정적 선언. card타입 자체에 부여되는 함수. id를 하나씩 증가시켜 반환한다.
    static func getUniqueIdentifier() -> Int{
        identifierFactory += 1
        return identifierFactory
    }
    
    //카드의 초기화가 일어날 때 마다 고유의 id를 생성한다.
    init(){
        self.identifier = Card.getUniqueIdentifier()
    }

}

 

import Foundation

class Concentration{
    //card타입의 배열 생성
    var cards = [Card]()
    
    //유일하게 앞면 상태의 카드를 나타내는 속성 이후에 두번째로 오픈되는 카드와 id를 비교하기위해 사용된다.
    var indexOfOneAndOnlyFaceupCard:Int?
    
    //카드를 선택할 때 수행할 함수이다. 유저가 선택한 카드가 몇번째 배열인지 배개변수로 받는다.
    func chooseCard(at index: Int){
        print("card's id:",cards[index].identifier)
        print("card's index:",index)
        
        if !cards[index].isMatchedUp{
            //유저가 선택한 카드가 만약 쌍을 이루지 못한 상태라면,
            if let matchIndex = indexOfOneAndOnlyFaceupCard, matchIndex != index{
                //만약 유저가 선택한 카드 제외, 앞면인 상태의 카드가 한장이 존재한다면, 해당카드와 id를 비교
                if cards[matchIndex].identifier == cards[index].identifier{
                    //서로의 id가 같다면, 두 카드는 쌍을 이룬 상태로 전환
                    cards[matchIndex].isMatchedUp = true
                    cards[index].isMatchedUp = true
                }
                //그렇지 않다면 유저가 선택한 카드는 앞면을 향하게 한다.
                cards[index].isFaceUp = true
                //더이상 유일하게 앞면을 보고있는 카드는 없다.
                indexOfOneAndOnlyFaceupCard = nil
            } else{
                //indexOfOneAndOnlyFaceupCard값이 nil이라면, 즉 유저가 선택한 카드 이외 앞면인 카드가 한장보다 많으면,
                for flipDownIndex in cards.indices{
                    //모든 카드를 뒷면으로 바꾼다.
                    cards[flipDownIndex].isFaceUp = false
                }
                //유저가 선택한 카드는 앞면으로 바꾼다.
                cards[index].isFaceUp = true
                //유저가 선택한 카드는 앞면을 바라보는 유일한 한장이다.
                indexOfOneAndOnlyFaceupCard = index
            }
        }
    }
    
    //생성할 카드쌍의 수를 입력받는다. 즉 입력받는 수의 두배만큼 카드를 만든다.
    init(numberOfPairsOfCards:Int){
        for _ in 0..<numberOfPairsOfCards{
            //동일카드 한쌍을 배열에 추가한다.
            let card = Card()
            cards += [card,card]
        }
        
        //TODO: 카드섞기
        for index in 0..<numberOfPairsOfCards{
            let randomIndex = Int(arc4random_uniform(UInt32(numberOfPairsOfCards)))
            let swpidentifier = cards[index].identifier
            cards[index].identifier = cards[randomIndex].identifier
            cards[randomIndex].identifier = swpidentifier
        }
    }
}

View

Controller

import UIKit

class ViewController: UIViewController {
    //게임 생성. 카드 쌍의 수는 카드버튼 갯수의 절반이다.
    lazy var game = Concentration(numberOfPairsOfCards: cardButtons.count+1/2)
    
    //카드를 뒤집은 수를 나타내는 레이블.
    @IBOutlet weak var flipCountLabel: UILabel!
    
    //flipCountLabel에 표시할 변수. 당연하게도 0부터 시작한다.
    var flipCount = 0 {
        //변수의 값 변동이 일어날 때마다 수행되는 메소드. flipCountLabel에 변경된 값을 전달하고 표시한다.
        didSet{
            flipCountLabel.text = "flipCount: \(flipCount)"
        }
    }
    
    //UIButton을 카드모양으로 배치하였다. 해당 카드버튼들을 배열로 담았다.
    @IBOutlet var cardButtons : [UIButton]!
    
    //사용자가 카드를 터치할 때의 이벤트 함수. 어떤 카드가 터치된건지 UIButton을 매개변수로 넘긴다.
    @IBAction func touchCard(_ sender: UIButton) {
        if let cardNumber = cardButtons.firstIndex(of: sender){
            //sender 즉 사용자가 터치한 카드가 cards배열에서 몇번째 카드인지 넘겨준다.
            game.chooseCard(at: cardNumber)
            //뷰에 모델의 변경사항을 반영한다.
            updateViewFromModel()
        } else{
            //만약 카드가 cards배열에 없다면 오류문 출력
            print("card is not in cardsArray")
        }
    }
    
    //모델의 변경사항을 뷰에 알려준다.
    func updateViewFromModel() {
        for index in cardButtons.indices{
            //카드버튼과 카드는 배열 속 번호가 동일하다.
            let button = cardButtons[index]
            let card = game.cards[index]
            
            if card.isFaceUp{
                //앞면인 카드가 있다면, 카드에 해당하는 카드버튼에 이모지를 삽입
                button.setTitle(emoji(for: card), for: UIControl.State.normal)
                button.backgroundColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
                
                if !card.isMatchedUp{
                    //아직 짝이 없는 상태의 카드라면 flipCount증가.
                    flipCount += 1
                }
            } else{
                //카드가 뒷면인 상태라면 해당 카드버튼은 이모지를 지우고 뒷면으로 바꾼다.
                button.setTitle("", for: UIControl.State.normal)
                //카드가 만약 짝이 맞는 상태라면 투명하게 그렇지 않다면 주황색으로 바꾼다.
                button.backgroundColor = card.isMatchedUp ? #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0) : #colorLiteral(red: 1, green: 0.5763723254, blue: 0, alpha: 1)
            }
        }
    }
    
    //이모티콘 후보들이다. 배열로 담아놓았다.
    var emojiChoices = ["🦇", "😱", "🙀", "👿", "🎃", "👻", "🍭", "🍬", "🍎"]
    
    //emojiChoices에서 이모지를 뽑아 [카드의 id: 이모지] 쌍으로 딕셔너리를 생성한다.
    var emoji = [Int:String]()
    
    //카드에 이모지를 그려넣는 함수. 카드를 매개변수로 받는다. 이모지를 반환한다.
    func emoji(for card: Card) -> String{
        if emoji[card.identifier] == nil, emojiChoices.count > 0{
            //만약 매개변수 카드의 id가 emoji딕셔너리의 키로 존재하지 않는다면, 이모지 후보에서 랜덤하게 뽑아온뒤 [카드id:이모지] 딕셔너리에 추가한다.
            let randomIndex = Int(arc4random_uniform(UInt32(emojiChoices.count)))
            emoji[card.identifier] = emojiChoices.remove(at: randomIndex)
        }
        
        //카드 id를 키값으로 이모지 반환
        return emoji[card.identifier] ?? "?"
    }
}

lazy var

Cannot use instance member 'cardButtons' within property initializer;

property initializers run before 'self' is available

컨트롤러를 작성하면서 새롭게 배운것은 lazy이다.  game을 생성할 때 카드쌍의 수를 입력해야 하는데, 카드의 갯수를 늘리거나 줄이는 경우에, 이에 대응하여 game이 유연하게 동작하는 게 하기 위해 cardButtons 배열의 갯수를 읽어오도록 했다. 우선 스위프트는 효율적인 메모리관리를 위해 변수의 초기화가 이루어져야만 사용을 할 수 있다. 하지만 game변수를 초기화하기 위해 속성을 초기화하려다보니 속성을 초기화하려면 변수가 초기화 되어있어야 한다고 오류가 뜨고 말았다. 이러한 꼬인 부분을 해결하기 위해 사용한 것이 lazy였다. lazy로 선언된 변수는 일단 초기화가 완료된 것으로 취급이 되고, 해당 변수가 필요하게 되면 초기화가 시작된다. 이렇게 해서 상호간에 일으키는 오류를 해결했던 점이 흥미로웠다. 

 

optional ?? unwrap

emoji(for: card) 함수를 보면 마지막에 return emoji[card.identifier] ?? "?"  부분이 있다. 이러한 표현방법을 다른곳에서도 자주 봤지만 뜻을 잘 몰랐고 이번 강의를 통해서 이해하게 되었다. 해당 코드는 본래 아래와 같은 표현이다. 

if emoji[card.identifier] != nil {
    return emoji[card.identifier]!
} esle {
    return "?"
}

emoji딕셔너리가 카드의 id로 key를 가지고 있으면 해당하는 value를 unwrap하여 반환하고, 해당 키를 가지고 있지 않으면 "?"를 반환하는 코드를 return emoji[card.identifier] ?? "?"라는 한줄로 대치한 것이다. 

 

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

day06_multiTouch  (0) 2022.03.26
day05_view  (0) 2021.12.30
day04_swift_part2  (0) 2021.10.25
day03_swift_part1  (0) 2021.10.12
day01_ios  (0) 2021.09.28

edwith를 통해 스탠포드대학의 ios강의를 번역자막을 보면서 시청할수있다. 해당 강의를 시청하며 정리한 내용을 적어보려고 한다. 


what's in ios?

ios에 대한 간략한 설명을 해보자면 초기의 os X for iphone에서 3버전에는 iphone os, 2010년 4버전이 나오면서 ios라는 이름으로 정착되었다. ios는 크게 cocoa touch, media, core service, 

core OS로 구분되어 사용자와 하드웨어간의 의사소통을 담당한다.

 

사용자

cocoa touch

media

core service

core OS

하드웨어

 

cocoa touch는 전반적인 ui를 담당한다. 버튼, 슬라이더, 맵킷, 카메라 등 사용자와 가장 가까운 부분의 처리를 수행한다.

media는 말 그대로 비디오, 오디오, 이미지 등 ios의 미디어 부분을 할당한다. 

core service는 core location, network와 같은 핵심적인 서비스를 담당한다. 

core OS는 BSD계열 unix로 구성되어있으며, 당연하게도 c언어 베이스이다. 

 

해당 강의에서는 cocoa touch와 core service에 대해 다룰 예정이다.


Demo

이번 강의에 대한 카드뒤집기 데모를 만들었다. 

import UIKit


class ViewController: UIViewController {
    //카드를 뒤집은 수를 나타내는 레이블.
    @IBOutlet weak var flipCountLabel: UILabel!
    
    //flipCountLabel에 표시할 변수. 당연하게도 0부터 시작한다.
    var flipCount = 0 {
        //변수의 값 변동이 일어날 때마다 수행되는 메소드. flipCountLabel에 변경된 값을 전달하고 표시한다.
        didSet{
            flipCountLabel.text = "flipCount: \(flipCount)"
        }
    }
    
    //카드를 뒤집는 함수. 카드에 그려질 이모티콘(문자열)과 어떤 카드에 그릴지 UIButton을 매개변수로 받는다.
    func flipCard(withEmoji emoji:String, on button:UIButton) {
        if button.currentTitle == emoji{
            //카드가 앞면일 경우. 카드의 현재 문양(currentTitle)과 배개변수로 받은 이모티콘이 같다.
            button.setTitle("", for: UIControl.State.normal)
            button.backgroundColor =  colorLiteral(red: 1, green: 0.5763723254, blue: 0, alpha: 1)
        } else{
            //카드가 뒷면일 경우. 카드에 배개변수로 받은 이모티콘을 그리고(setTitle) 배경을 흰색으로, flipCount를 증가시킨다.
            button.setTitle(emoji, for: UIControl.State.normal)
            button.backgroundColor =  colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
            //flipCount가 증가되면 didSet이 수행된다.
            flipCount += 1
        }
    }
    
    //UIButton을 카드모양으로 배치하였다. 해당 카드들을 배열로 담았다.
    @IBOutlet var cards: [UIButton]!
    
    //카드에 그려넣을 그림을 이모티콘으로 표현한다. 배열로 담았다.
    let emojis = ["🐶","🦊","🐶","🦊"]
    
    //사용자가 카드를 터치할 때의 이벤트 함수. 어떤 카드가 터치된건지 UIButton을 매개변수로 넘긴다.
    @IBAction func touchCard(_ sender: UIButton) {
        if let cardNumber = cards.firstIndex(of: sender){
            //sender 즉 사용자가 터치한 카드가 cards배열에 몇번째 배열인지 반환한다.
            //해당 카드와 같은 배열번호의 이모지를 배개변수로 넘겨 flipCard()함수 수행.
            flipCard(withEmoji: emojis[cardNumber], on: sender)
        } else{
            //만약 카드가 cards배열에 없다면 오류문 출력
            print("card is not in cardsArray")
        }
    }
}

여기서 인상깊었던 부분은 함수부분이다. 처음 스위프트를 배울 때 함수선언부분에서 왜 매개변수명을 굳이 두개나 적을까 궁금했는데, 새touchCard()flipCard() 호출부분을 보면 스위프트가 어째서 매개변수명을 두개로 적는지 알 수 있었다. 애플은 코드를 읽을 때 최대한 사람의 입장에서 자연스럽게 코드를 읽을 수 있기를 바랬고, 그러한 의도가 호출부에서 잘 나타났다.

 

flipCard(withEmoji: emojis[cardNumber], on : sender) -> 전달자에 들어있는 이모지가 그려진 카드를 뒤집어라. 

 

반대로 선언부에서는 withemojion이라는 변수명이 부자연스러우니 emojibutton이라는 변수명으로 사용이 된다. 이러한 변수명의 선택은 코드의 유지보수면에서 큰 도움이 된다고 생각한다. 강의를 많이 찾아본 것은 아니지만 적어도 학원강의에서는 이러한 부분을 전혀 알려주지 않았던 것을 보면 확실히 본토 대학강의가 훨씬 더 영양가있다고 생각한다.

 

이번에 didSet{ }에 대해서도 처음 배웠다. 속성감시자라고 번역이 되어있는데, 우리가 알고있는 변수는 스위프트에서 속성이라 부른다고 한다. 속성(변수)값이 변경될 경우 항상 수행되는 작업을 명시해줄 수 있다. 해당 데모에서 flipCount를 증가할 때 마다 레이블 수정작업을 따로 적을 필요없이 didSet{ }에 레이블 수정작업을 적어주어 코드라인을 줄일 수 있게 되었다.

 

 

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

day06_multiTouch  (0) 2022.03.26
day05_view  (0) 2021.12.30
day04_swift_part2  (0) 2021.10.25
day03_swift_part1  (0) 2021.10.12
day02_MVC  (0) 2021.10.03

+ Recent posts