티스토리 뷰

지금까지는 화면을 만들 때 view의 내용을 코드에 직접 기입해서 만들었다. 예를 들어 이름을 표현하기 위해서 UILabel을 이렇게 만들었다. 

let nameLabel = UILabel(frame: CGRect(x: 0, y:0, width: 200, height: 50))
nameLabel.text = "사나"    // asset name

위처럼 만드는 것은 빠르게 화면을 구성할 때는 좋지만 나중에 화면의 내용이 바뀌거나 추가될 때 코드를 직접 수정해야 한다. 그래서 "사나" 와 같은 데이터는 따로 저장을 해 두고 데이터로 부터 화면을 구성할 수 있도록 구축하면 좀 더 유연하게 유지보수 할 수 있다. 그 유명한 MVC(Model View Controller) 패턴이다.

 

 

 

MVC Pattern

디자인 패턴은 개발 방법일 뿐 정답이 있는 게 아니기 때문에 이 패턴이 추구하는 방향만 간략하게 알아보자. MVC패턴은 말 그대로 프로그램을 Model, View, Controller로 나눠서 개발하는 것을 말하며 각 요소들은 서로 소통하며 맡은 바 임무를 수행한다.

 

  • Model : 내부적인 데이터를 처리한다
  • View : 화면에 보여지는 것을 말한다.
  • Controller : 사용자로부터 직접 입력을 받고, 전체적인 흐름을 제어한다.

 

IOS개발 뿐 아니라 소프트웨어 공학 전반에 걸쳐 적용되는 패턴이기 때문에 딱 이게 정답이라고 할 수는 없지만 지금까지 만든 화면에 대입해서 생각해보면 "사나", "프로필 사진" 등 데이터들은 Model이라고 볼 수 있고, UILabel, UIImageView 등 화면에 그려지는 모든 것들은 View라고 볼 수 있다. 직접 구현 하지는 않았지만 UIScrollView의 경우 사용자가 아래로 드래그하면 그려지던 view들이 위로 올라가서 그려지는데, 이런 제어가 Controller의 역할이라고 볼 수 있다. 지금까지는 이 모든 것을 MainController에서 모두 처리하도록 했지만 이걸 분리하는 것이 이번 포스팅의 목표다.

 

실제 코드에서도 나눠서 동작하도록 해보자.

 

 

 

JSON

MVC패턴에서 Model은 내부적인 데이터를 처리한다고 했다. 우리는 이 데이터를 JSON으로 처리할 것이다. 실제 데이터베이스에서 값을 검색해서 가지고 오더라도 JSON으로 받는 경우가 많기 때문에 알고 있으면 도움이 될 것이다. JSON은 Attribute-Value 쌍으로 나열된 데이터로 {}로 구분한다. 여러 개의 Attribute-Value 쌍은 쉼표로 구분하고, []로 배열을 만들어서 사용할 수 있다.

사나 님의 프로필을 JSON데이터로 만들어 보자. 먼저 지금 사용하는 데이터인 이름, 프로필 사진 경로가 필요하다. 거기에 추가적인 내용을 넣기 위해서 infos라는 String-String 배열을 추가했다. 이 배열에는 제목과 내용이 입력되기 때문에 문자열 이라면 다 넣을 수 있다.

{
	"name": "미나토자키 사나",
	"profileImagePath": "sana_profile" 
	"infos": [
		{
			"title": "생일",
			"content":, "1996년 12월 29일"
		},
		{
			"title": "국적",
			"content": "일본"
		}
	] 
}

원한다면 더 많은 정보를 넣을 수도 있지만 이 데이터를 사용하는 것이 목적이기 때문에 여기까지만 만들자.

 

 

 

Codable

위에서 만든 JSON 데이터를 프로젝트에 가지고 와야 한다. .txt파일이든, .json 파일이든 이 데이터를 코드로 읽는 것을 JSON parsing 이라고 한다. JSON의 규칙은 간단하다. { 기호를 읽으면 데이터의 시작이라는 뜻이다. 거기 부터 : 기호를 만나는 부분 까지가 첫번째 데이터의 Attribute, : 기호 부터 , 기호를 만날 때 까지가 그 Attribute의 Value. [ 기호는 배열의 시작이며, ] 기호는 배열의 끝. } 기호는 데이터의 끝을 뜻한다.

이 규칙을 적용해서 직접 JSON parsor를 만들어 봐도 되지만 swift가 제공하는 Codable이라는 json protocol을 사용하면 아주 쉽게 데이터를 읽고 쓸 수 있다. 단 codable은 이미 json에 어떤 데이터가 들어있는지 어느정도 알고 있다는 전제가 필요하다. 다행히 우리는 데이터의 구성을 알고 있다. 그에 맞게 구조체를 만들고 Codable protocol을 따르도록 하면 json 데이터를 받아서 저장할 수 있는 구조체가 된다.

 

사실 이렇게 간단하게 모든 문제를 해결할 수 있는 솔루션이 있을 리 없다. 지금이야 데이터도 간단하고 내용도 모두 알고 있지만 실제로 데이터가 복잡해지면 다양한 경우가 생기고 그에 따라 깊은 이해가 필요하다. 이는 codable을 따로 포스팅 할 때 알아보자.

 

프로필 데이터는 2개의 JSON을 사용한다. 하나는 전체 프로필을 의미하는 데이터, 하나는 infos 배열에 들어가는 title, content를 가진 JSON.

 

struct Info : Codable {
	var title: String
	var content: String
}

struct Profile : Codable {
	var name: String
	var profileImagePath: String
	var infos: Array<Info>
}

 

Codable을 상속 받아서 JSON 데이터를 읽어들일 수도, 구조체의 내용을 JSON으로 쓸 수도 있게 만들었다. 구조체에 protocol을 채용한 것 이기 때문에 그냥 구조체를 사용하는 것 처럼 다룰 수 있다.

 

// decode json data to profile: Profile

let nameLabel = UILabel()
nameLabel.text = profile.name

 

 

 

JSONDecoder

지금까지 JSON 데이터를 담을 수 있는 그릇을 만들었고, 실제로 그 그릇에 데이터를 담는 것은 JSONDecoder 라는 것을 사용한다. 원래는 로컬 파일로 저장된 데이터를 읽어서 decode하는 코드로 설명하려고 했지만 이미 포스팅이 산으로 가고 있고(원래는 MVC로 코드를 분할하는 것이 목적) 파일 입출력까지 추가하면 너무 복잡해질 것 같아서 data는 이런 식으로 이미 있다고 가정한다.

 

let jsonData = """
{
	"name": "미나토자키 사나",
	"profileImagePath": "sana_profile" 
	"infos": [
		{
			"title": "생일",
			"content":, "1996년 12월 29일"
		},
		{
			"title": "국적",
			"content": "일본"
		}
	] 
}
""".data(using: .utf8)!

 

decode는 언제든지 실패할 수 있기 때문에 try를 강제한다. 이렇게 오류가 발생할 수 있는 부분에 컴파일 단계에서 오류를 제어할 수 있도록 하는 점이 참 좋다.

 

if let profileData = try? JSONDecoder().decode(Profile.self, from: jsonData) {
	let profileImage = UIImageView(frame: CGRecct(x: 0, y: 0, width: 200, height: 200))
	profileImage.image = UIImage(named: profileData.profileImagePath)

	let nameLabel = UILabel(frame: CGRect(x: 0, y:0, width: 200, height: 50))
	nameLabel.text = profileData.name

	// make other views with profileData

} else {
	// handle error
}

 

 

 

Model

위 처럼 만들어도 좋지만 여전히 데이터를 읽는 부분과 view를 만드는 부분이 한 소스코드 안에 있기 때문에 별로 좋지 않아보인다. 이렇게 읽은 profileData를 관리할 수 있는 클래스를 만들어서 view와 분리해 보자. 새로운 파일을 만들고 CocoaTouch 클래스가 아닌 일반 클래스로 만든 후 데이터를 관리할 수 있는 기능을 추가해서 Model로 분리할 수 있다. 앞으로 프로필의 내용을 수정할 필요가 있더라도 MainController의 코드를 수정할 필요 없이 JSON 데이터만 수정하는 것으로 바로 대응할 수 있다.

 

// DataManager.swift
class DataManager {
	var profileData: Profile

	init() {
		do {
			profileData = try JSONDecoder().decode(Profile.self, from: jsonData)
		} catch {
			// initialize profileData to default value
		}
	}
}

// MainViewController.swift
let dataSource = DataManager()
let nameLabel = UILabel()
nameLabel.text = dataSource.profileData.name

 

이렇게 model을 만들어 보았는데, 지금까지 만든 화면에는 이름을 적을 수 있는 label만 있을 뿐 생일과 국적을 적을 view가 없다.

 

 

 

View

아직 사용자의 입력에 따라 view의 상태가 바뀌는 내용이 없기 때문에 MainViewController에 있는 view를 추가하는 부분이 바로 View라고 볼 수 있다. 그래도 물리적으로 코드를 분리하면 좀 더 모듈화 될 수 있기 때문에 프로필 상세 내용을 적는 부분을 따로 만들어 보자.

 

데이터에 맞게 title과 content를 적을 수 있는 label들로 구성한다. view 내부에서 자리잡을 수 있도록 constraint를 설정할 수 있다.

 

새로운 파일을 만들고 CocoaTouch Class template를 사용한다. UIView를 상속받아서 생성자에서 내용을 구성한다. ViewController의 ViewDidLoad에서 했던 작업을 똑같이 한다고 생각하면 된다. constraint도 한번에 적용할 수 있도록 함수로 만든다.

// SimpleRow.swift
class SimpleRow : UIView {
	lazy var titleLabel: UILabel = {
		let titleLabel = UILabel()
		titleLabel.translatesAutoresizingMaskIntoConstraints = false
	
		return titieLabel
	}()

	// same with contentLabel

	init(title: String, content: String) {
		super.init(frame: CGFrame.zero)

		self.translatesAutoresizingMaskIntoConstraints = false

		titleLabel.text = title
		self.addSubview(titleLabel)
		titleLabel.trailingAnchor.constraint(equalTo: self.centerXAnchor, constant: -50).isActive = true
		titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
		
		contentLabel.text = content
		self.addSubview(contentLabel)
		contentLabel.leadingAnchor.constraint(equalTo: self.centerXAnchor, constant: -40).isActive = true
		contentLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
		contentLabel.widthAnchor.constraint(equalToConstant: 180).isActive = true
	}
}

 

이렇게 만든 SimpleRow는 MainViewController에서 동적으로 생성할 수 있다.

 

// MainViewController.swift
var top: UIView = nameLabel
var margin: CGFloat = 30

for info in DataManager.profileData.infos {
	let row = SimpleRow(title: info.title, content: info.content)
	scrollBody.addSubView(row)
    // constraints...
	top = row
	margin = 5
}

 

nameLabel로부터 거리는 좀 더 멀고, 각 row들은 그것보다 간격이 더 좁아야 돼서 조금 하드코딩이 된 것 같은데, JSON에 따라 row가 추가되기 때문에 코드가 더 유연해졌다. 이제 JSON을 수정하는 것 만으로 취미도, 좋아하는 색 같은 것도 추가할 수 있다.

 

 

 

VStack

후반부 constraint를 설정하는 부분을 생략했는데, 만들다 보니 어차피 모든 view는 위에서 아래로 쌓아가면서 추가되기 때문에 특정 규칙만 적용하면 자동으로 constraint를 만들 수 있지 않을까 하는 생각이 들었다. SwiftUI에는 VStack이라는 view가 있어서 이 안에 들어가는 view들을 모두 아래로 쌓아주고, 크기를 넘어갈 경우 자동으로 스크롤까지 만들어준다. 이와 비슷하게 constraint를 자동으로 설정해보자.

UIView 클래스를 확장해서 VStack 함수를 추가한다.

 

extension UIView {
	func VStack(on: UIView? = nil, margin: CGFloat = 0) {
		guard let parent = superview else {
			return
		}
		
		if self.translatesAutoresizingMaskIntoConstraints {
			self.translatesAutoresizingMaskIntoConstraints = false
		}
	
		if let top = on {
			self.topAnchor.constraint(equalTo: top.bottomAnchor, constant: margin).isActive = true
		} else {
			self.topAnchor.constraint(equalTo: parent.topAnchor, constant: margin).isActive = true
		}

		self.widthAnchor.constraint(equalTo: parent.widthAnchor).isActive = true
		self.centerXAnchor.constraint(equalTo: parent.centerXAnchor).isActive = true

		if self.frame.height > 0 {
			self.heightAnchor.constraint(equalToConstant: self.frame.height).isActive =  true
		}
	}
}

 

이제 이 규칙을 row들에게 적용하기 위해 위 코드 constraint... 부분에 VStack을 호출해주자. 자동 constraint가 설정된다.

 

for info in DataManager.profileData.infos {
	let row = SimpleRow(title: info.title, content: info.content)
	scrollBody.addSubView(row)
	row.VStack(on: top, margin: margin)
	top = row
	margin = 5
}

 

 

 

Dynamic contentSize

UIScrollView는 contentSize라는 범위를 가진다. scrollView를 보면, 실제 scrollView의 프레임은 화면에만 꽉 차게 설정했지만 contentSize를 위아래로 길게 설정해서 스크롤을 할 수 있게 만들었다. contentSize가 frame보다 더 크기때문에 아무 subView가 들어있지 않아도 위아래로 스크롤은 할 수 있다.

 

scrollView.contentSize = CGSize(width: scrollView.frame.width, height: 1000)

 

문제는 row가 데이터에 따라 동적으로 늘어날 수도, 줄어들 수도 있다는 점인데, 1000으로 고정해버리면 데이터가 너무 적을 경우 공백이 생기고, 반대로 데이터가 너무 많아져서 1000을 넘어가면 볼 수 없게 된다. 이 값을 상수로 두지 말고, viewDidLoad의 마지막에 모든 view의 높이를 합친 값으로 설정하자.

 

var contentHeight: CGFloat = 20    // default margin

// add views
// contentHeight += viewHeight

scrollView.contentSize = CGSize(width: scrollView.frame.width, height: contentHeight)

 

이제 scroll 범위가 들어있는 내용에 따라 동적으로 적용된다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함