iOS SwiftUI : Data 사용 UI

참고: https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation

위 URL 에서 다운로드받을 수 있는 예제 프로젝트로부터 landmarkData.json 파일을 iosdiary 프로젝트로 드래그앤드롭하여 가져온다.



이 데이터를 핸들링하기 위해서 Landmark.swift 파일을 생성한다.


landmarkData.json 에 정의되어 있는 항목을 Landmark 에 정의한다. (강의에서와 조금 다르게 json 파일을 구성하고 있는 항목 순서대로 정의해봤다)

import Foundation

struct Landmark: Hashable, Codable {
	var name: String
	var category: String
	var city: String
	var state: String
	var id: Int
	var isFeatured: Bool
	var isFavorite: Bool
	var park: String
	//coordinates 생략
	var description: String
        var imageName: String
}

Hashable 과 Codable 은 프로토콜.

Hashable 은 각 항목을 구분할 수 있게 해주기 위한 것이고, Codable 은 데이터 파일로부터 읽어들이기 쉽게 해주기 위한 것이다. 라고 아주아주 간단하게 설명할 수 있다.

이미지의 표현을 위해서 참고 프로젝트의 Images 폴더에 있는 이미지 파일들을 Assets 로 드래그앤드롭하여 가지고 왔다.


landmarkData.json 에 정의되어 있는 imageName 은 방금 Assets 에 포함시킨 이미지 리소스의 이름인데, UI 에서 이미지를 표현하기 위해서 처리하는 방법에 대해서는 뒤에서 구현해볼 것이다.

샘플 프로젝트에서 설명하고 있는 위치정보에 대해서는 여기에서 다루지 않기로 한다.

데이터 저장소 파일이 landmarkData.json 으로 존재하고, 개별 데이터 구조를 Landmark 구조체로 정의한 Landmark.swift 파일이 존재한다. landmark.Data.json 에 저장되어 있는 데이터를 여러개의 Landmark 항목으로 읽어들이기 위한 로직이 필요하므로 ModelData.swift 파일을 만들어 로직을 구성해본다.

Swift File 템플릿을 사용하여 ModelData.swift 라는 이름으로 파일을 추가하고, 다음과 같이 구현한다.

import Foundation

func load<T: Decodable>(_ filename: String) -> T {
	let data: Data

	guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
	else {
		fatalError("Couldn't find \(filename) in main bundle.")
	}

	do {
		data = try Data(contentsOf: file)
	} catch {
		fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
	}

	do {
		let decoder = JSONDecoder()
		return try decoder.decode(T.self, from: data)
	} catch {
		fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
	}
}

ModelData.swift 에 landmarkData.json 파일로부터 데이터를 로드하여 저장할 배열을 선언한다.

import Foundation

var landmarks: [Landmark] = load("landmarkData.json")

func load<T: Decodable>(_ filename: String) -> T {
	let data: Data

	guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
	else {
		fatalError("Couldn't find \(filename) in main bundle.")
	}

	do {
		data = try Data(contentsOf: file)
	} catch {
		fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
	}

	do {
		let decoder = JSONDecoder()
		return try decoder.decode(T.self, from: data)
	} catch {
		fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
	}
}

landmarkData.json 에 저장되어 있는 한 개의 아이템을 표시하기 위한 뷰를 만들어보자.

User Interface 항목 중 SwiftUI View 를 선택하고,


LandmarkRow.swift 라는 이름으로 파일을 만들어보자.


자동으로 작성된 코드는 아래와 같다.

//
//  LandmarkRow.swift
//  iosdiary
//
//  Created by woohaha on 2024/04/19.
//

import SwiftUI

struct LandmarkRow: View {
    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
    LandmarkRow()
}

이 뷰에서 처리할 아이템은 landmarkData.json 에 저장되어 있는 여러 개의 데이터중 1개 항목이고, 이 1개 항목은 Landmark 구조체로 정의해 놓은 상태이므로 데이터 처리를 위해서 Landmark 구조체형 변수를 선언해 두자.

import SwiftUI

struct LandmarkRow: View {
	var landmark: Landmark

    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
    LandmarkRow()
}

위와 같이 landmark 멤버를 선언하면 preview 에러가 발생하는데,


이 에러를 수정하려면 미리보기에 landmark 멤버에 대한 요소를 넘겨주어야 한다.

import SwiftUI

struct LandmarkRow: View {
	var landmark: Landmark

    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
	LandmarkRow(landmark: landmarks[0])
}

landmarks 는 ModelData.swift 에 정의해둔 Landmark 형 변수의 배열을 담을 변수인데, landmarkData.json 으로부터 읽어들인다. 이 중에서 첫 번째 요소를 넘기고 있다.

ModelData.swift
import Foundation

var landmarks: [Landmark] = load("landmarkData.json")

func load<T: Decodable>(_ filename: String) -> T {
	let data: Data
...

LandmarkRow 에 Hello, World! 라고 표시되는 Text 뷰를 landmark 의 name 을 표시하도록 수정한다.

import SwiftUI

struct LandmarkRow: View {
	var landmark: Landmark

    var body: some View {
        //Text("Hello, World!")
	Text(landmark.name)
    }
}

#Preview {
	LandmarkRow(landmark: landmarks[0])
}

landmark.name 의 왼쪽에 이미지를 표시하기 위해서 HStack 으로 감싼다.

import SwiftUI

struct LandmarkRow: View {
	var landmark: Landmark

    var body: some View {
	HStack {
	    Text(landmark.name)
	}
    }
}

#Preview {
	LandmarkRow(landmark: landmarks[0])
}

landmark 의 이미지를 표시하기 위해서 Landmark 구조체에 image 프로퍼티를 다음과 같이 추가한다.


Image 는 SwiftUI 를 import 해야 한다.

import Foundation
import SwiftUI

struct Landmark: Hashable, Codable {
	var name: String
	var category: String
	var city: String
	var state: String
	var id: Int
	var isFeatured: Bool
	var isFavorite: Bool
	var park: String
	//coordinates 생략
	var description: String
	var imageName: String
	var image: Image {
		Image(imageName)
	}
}

imageName 으로부터 Image 형 컴포넌트를 생성하므로 LandmarkRow 에 아래와 같이 Image 를 표시할 수 있다.

import SwiftUI

struct LandmarkRow: View {
	var landmark: Landmark

    var body: some View {
		HStack {
			landmark.image
			Text(landmark.name)
		}
    }
}

#Preview {
	LandmarkRow(landmark: landmarks[0])
}


이미지 크기를 50×50 으로 조정하기 위해서 resize 코드를 추가한다.

...
    var body: some View {
		HStack {
			landmark.image
				.resizable()
				.frame(width: 50, height:50)
			Text(landmark.name)
			Spacer()
		}
    }
...

맨 오른쪽에 여백을 두기 위해서 Spacer() 를 추가하였다.

아래와 같이 미리보기를 구성할 수도 있다.

#Preview {
	LandmarkRow(landmark: landmarks[0])
}

#Preview {
	LandmarkRow(landmark: landmarks[1])
}

#Preview {
	Group {
		LandmarkRow(landmark: landmarks[0])
		LandmarkRow(landmark: landmarks[1])
	}
}


Landmark 데이터를 리스트 형태로 표시할 뷰를 하나 만들자.


SwiftUI View 를 추가하고 LandmarkList.swift 라고 이름짓자.

LandmarkRow 를 아이템으로 갖는 List 뷰를 아래와 같이 코딩하자.


landmarks 의 인덱스 0의 요소와 인덱스 1의 요소를 직접 명시하여 2개의 LandmarkRow 를 표시하고 있는데, 동적으로 landmarks 에 저장된 아이템의 갯수만큼 표시하도록 변경해보자.

import SwiftUI

struct LandmarkList: View {
    var body: some View {
//		List {
//			LandmarkRow(landmark: landmarks[0])
//			LandmarkRow(landmark: landmarks[1])
//		}
		List(landmarks, id: \.id) { landmark in
			LandmarkRow(landmark: landmark)
		}
    }
}

#Preview {
    LandmarkList()
}

미리보기는 다음과 같이 표시된다.


landmarks 에 저장된 아이템은 id 속성으로 구분될 수 있는데, 이를 Landmark 구조체에 정의해본다.

Identifiable 프로토콜은 id 속성을 필요로 한다.

import Foundation
import SwiftUI

struct Landmark: Hashable, Codable, Identifiable {
	var name: String
	var category: String
	var city: String
	var state: String
	var id: Int
	var isFeatured: Bool
	var isFavorite: Bool
	var park: String
	//coordinates 생략
	var description: String
	var imageName: String
	var image: Image {
		Image(imageName)
	}
}

이렇게 하고나면 LandmarkList 에서 id 식별자에 대한 정의를 생략할 수 있다.

import SwiftUI

struct LandmarkList: View {
    var body: some View {
//		List {
//			LandmarkRow(landmark: landmarks[0])
//			LandmarkRow(landmark: landmarks[1])
//		}
//		List(landmarks, id: \.id) { landmark in
//			LandmarkRow(landmark: landmark)
//		}
		List(landmarks) { landmark in
			LandmarkRow(landmark: landmark)
		}
    }
}

#Preview {
    LandmarkList()
}

이번에는 List 의 아이템을 클릭했을 때 상세한 내용을 보여주기 위한 DetailView 를 추가해보자.

SwiftUI View 템플릿을 선택하고 LandmarkDetail 이라는 이름으로 뷰를 생성하자.

//
//  LandmarkDetail.swift
//  iosdiary
//
//  Created by woohaha on 2024/04/20.
//

import SwiftUI

struct LandmarkDetail: View {
    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
    LandmarkDetail()
}

상세 뷰에는 앞서 만들어봤던 ContentView 의 내용이 표시되도록 동일하게 구성해보자.

import SwiftUI

struct LandmarkDetail: View {
    var body: some View {
		VStack(alignment: .leading) {
			CircleImage()

			Text("Turtle Rock")
				.font(.title)
			HStack {
				Text("Joshua Tree National Park")
				Spacer()
				Text("California")
			}
			.font(.subheadline)
		}
		.padding()
    }
}

#Preview {
    LandmarkDetail()
}

ContentView 에서 표시하던 내용을 LandmarkDetail 로 이동시켰으니 ContentView 에는 LandmarkList 가 표시되게 수정해보자.

import SwiftUI

struct ContentView: View {
    var body: some View {
	LandmarkList()
    }
}

#Preview {
    ContentView()
}

LandmarkList 에서 표시되고 있는 각 아이템을 선택했을 때 LandmarkDetail 뷰로 전환하면서 해당 항목의 내용이 표시되게 하려면 NavigationSplitView 를 사용한다.

import SwiftUI

struct LandmarkList: View {
    var body: some View {
		NavigationSplitView {
			List(landmarks) { landmark in
				LandmarkRow(landmark: landmark)
			}
		} detail: {
			Text("Select a Landmark")
		}
    }
}

#Preview {
    LandmarkList()
}

LandmarkList 를 NavigationSplitView 로 감싼 것이다.

각 항목에 대한 링크를 추가하기 위해서 NavigationLink 를 추가한다.

import SwiftUI

struct LandmarkList: View {
    var body: some View {
		NavigationSplitView {
			List(landmarks) { landmark in
				NavigationLink {
					LandmarkDetail()
				} label: {
					LandmarkRow(landmark: landmark)
				}
			}
		} detail: {
			Text("Select a Landmark")
		}
    }
}

#Preview {
    LandmarkList()
}

NavigationLink 를 추가한 결과로 미리보기에서는 > 화살표를 볼 수 있게 된다.


프로그램을 실행시켰을 때 List 에서 항목을 선택할 때마다 상세보기 뷰로 전환하지만, 상세보기 뷰에 보이는 내용은 동적으로 데이터를 받아오는 것이 아닌 상태이기 때문에 매번 동일한 내용이 보여진다.

우선 CircleImage 에서 보여줄 이미지를 멤버 프로퍼티로 추가해준다.

import SwiftUI

struct CircleImage: View {
	var image: Image

    var body: some View {
		//Image("turtlerock")
		image
			.clipShape(Circle())
    }
}

#Preview {
    CircleImage()
}

image 를 사용하는 코드로 수정해야 한다.

이때 미리보기로 데이터를 전달해주어야 한다.



LandmarkDetail 에서 CircleImage 에도 image 를 전달해 주어야 한다.


LandmarkDetail 에서는 Landmark 아이템을 필요로 한다.

import SwiftUI

struct LandmarkDetail: View {
	var landmark: Landmark

    var body: some View {
		VStack(alignment: .leading) {
			CircleImage(image: Image(landmark.imageName))

			Text("Turtle Rock")
				.font(.title)
			HStack {
				Text("Joshua Tree National Park")
				Spacer()
				Text("California")
			}
			.font(.subheadline)
		}
		.padding()
    }
}

LandmarkDetail 이 Landmark 아이템을 전달받아서 이미지를 표시할 수 있도록 수정했다.

제목 등도 Landmark 아이템의 프로퍼티를 사용하도록 변경해본다.

import SwiftUI

struct LandmarkDetail: View {
	var landmark: Landmark

    var body: some View {
		VStack(alignment: .leading) {
			CircleImage(image: Image(landmark.imageName))

			Text(landmark.name)
				.font(.title)
			HStack {
				Text(landmark.city)
				Spacer()
				Text(landmark.state)
			}
			.font(.subheadline)
		}
		.padding()
    }
}

#Preview {
	LandmarkDetail(landmark: landmarks[0])
}

LandmarkList 에서 LandmarkDetail 에게 적절한 파라미터를 전달해주어야 한다.

import SwiftUI

struct LandmarkList: View {
    var body: some View {
		NavigationSplitView {
			List(landmarks) { landmark in
				NavigationLink {
					LandmarkDetail(landmark: landmark)
				} label: {
					LandmarkRow(landmark: landmark)
				}
			}
		} detail: {
			Text("Select a Landmark")
		}
    }
}

LandmarkDetail 에 가장 긴 길이의 내용을 담고 있는 description 을 출력하도록 추가해본다.

import SwiftUI

struct LandmarkDetail: View {
	var landmark: Landmark

    var body: some View {
		VStack(alignment: .leading) {
			CircleImage(image: Image(landmark.imageName))

			Text(landmark.name)
				.font(.title)
			HStack {
				Text(landmark.city)
				Spacer()
				Text(landmark.state)
			}
			.font(.subheadline)

			Text(landmark.description)
		}
		.padding()
    }
}


제한된 높이에 모든 내용이 출력될 수 없기 때문에 말 줄임표(…) 가 표시되고 있다.

ScrollView 로 감싸서 스크롤이 가능하게 해본다.

import SwiftUI

struct LandmarkDetail: View {
	var landmark: Landmark

    var body: some View {
		ScrollView {
			VStack(alignment: .leading) {
				CircleImage(image: Image(landmark.imageName))

				Text(landmark.name)
					.font(.title)
				HStack {
					Text(landmark.city)
					Spacer()
					Text(landmark.state)
				}
				.font(.subheadline)

				Text(landmark.description)
			}
			.padding()
		}
    }
}


화면 범위를 벗어나는 내용은 스크롤을 해서 확인할 수 있게 된다.

네비게이션 타이틀에 대한 설정을 해보자.

import SwiftUI

struct LandmarkList: View {
    var body: some View {
		NavigationSplitView {
			List(landmarks) { landmark in
				NavigationLink {
					LandmarkDetail(landmark: landmark)
				} label: {
					LandmarkRow(landmark: landmark)
				}
			}
			.navigationTitle("Landmarks")
		} detail: {
			Text("Select a Landmark")
		}
    }
}

Navigation 뷰 내부의 List 에 대해서 navigationTitle 을 설정해서 리스트 제목이 표시되게 할 수 있다.


LandmarkDetail 에서 ScrollView 에 대해서 navigationTitle 을 설정해서 제목을 보이게 할 수 있고, navigationBarTitleDisplayMode 로 크기를 설정할 수 있다.

import SwiftUI

struct LandmarkDetail: View {
	var landmark: Landmark

    var body: some View {
		ScrollView {
			VStack(alignment: .leading) {
				CircleImage(image: Image(landmark.imageName))

				Text(landmark.name)
					.font(.title)
				HStack {
					Text(landmark.city)
					Spacer()
					Text(landmark.state)
				}
				.font(.subheadline)

				Text(landmark.description)
			}
			.padding()
		}
		.navigationTitle(landmark.name)
		.navigationBarTitleDisplayMode(.inline)
    }
}

미리보기에서는 위 코드의 결과가 보이지 않으므로 시뮬레이터로 실행시켜서 아래와 같이 확인할 수 있다.


Leave a Comment