본문 바로가기

iOS 개발/App 개발 프로젝트

FLO Music App - 재생 목록 화면 (Custom Cell)

목차 (눌러서 이동)

 

    Custom Cell 작성

    먼저 Test class 작성의 대략적인 순서를 파악하기 위해 필요한 것을 나열해보자.

     

    1. MusicCell class 가 필요. (identifier property 를 가져야 함.)

     

    2. 각 UI Components 를 원하는 Configuration 을 부여하며 선언하고 Cell 의 Content View 에 올리기.

     

    3. 원하는 Cell 형태에 맞게 각 UI Components 의 Constraints 작성.

     

    4. cellForRowAt indexPath 함수에서 호출해 Cell 내용을 채울 수 있게하는 함수 작성. ( configureCell( ) )

    Custom Cell class 작성

    먼저 MusicCell 이란 이름으로 Custom cell class 를 작성하고 PlayListViewController 의 tableView 가 이를 사용하도록 register 하게 하는 Failing Test 를 작성한다. 우리가 사용할 tableView 에 데이터를 제공한 뒤 cell 을 가져왔을 때 MusicCell 타입이라면 등록된 것이다.

     

    실제로 사용할 tableView 에 등록하는 것이므로 View Controller 와 Data Source 를 모두 작성해 동작시킨다. MusicCell class 에 static 상수를 두어 식별자로 활용하면 typo 로 인한 에러를 줄일 수 있다. 

     

    func test_MusicCell_class() {
    	let vc = PlayListViewController()
    	let music = [
    		Music(singer: "one", album: "", title: "", duration: 0, image: "", file: "", lyrics: "")
    	]
    	vc.dataSource = PlayListDataSource(music: music)
    	vc.loadViewIfNeeded()
    	
    	let cell = vc.tableView.dataSource?.tableView(vc.tableView, cellForRowAt: IndexPath(row: 0, section: 0))
    	
    	XCTAssertTrue(cell is MusicCell)
    	XCTAssertEqual(MusicCell.identifier, "MusicCell")
    }

     

    MusicCell class 를 작성하고 PlayListViewController 에서 UITableViewCell 을 register 하는 코드를 MusicCell 을 등록하도록 고친다.

     

    class MusicCell: UITableViewCell{
    	static let identifier = "MusicCell"
    }

     

    tableView.register(MusicCell.self, forCellReuseIdentifier: MusicCell.identifier)

    UI Components 선언

    다음으로 Cell 에 필요한 UI 요소들을 선언해야 한다. 대략 다음과 같은 형태의 Cell 이 필요하다.

     

     

    이를 위해 각 UI Component 를 생성하기 위한 Test 를 작성한다. 각 Component 가 기대되는 설정을 가지도록 선언되었는지, Cell 의 Content View 에 Subview 로 추가되었는지 확인한다. 

     

    func test_imageView_on_the_MusicCell() {
    	let cell = MusicCell()
    	let imgView = cell.albumImage
    	
    	let config = imgView.image == UIImage(named: "placeholder") &&
    		(imgView.contentMode == .scaleAspectFit) &&
    		imgView.clipsToBounds
    
    	XCTAssertTrue(config)
    	XCTAssertEqual(imgView.superview, cell.contentView)
    }
    
    func test_titleLabel_on_the_MusicCell() {
    	let cell = MusicCell()
    	let lbl = cell.titleLabel
    	
    	let config = lbl.textColor == .black &&
    		lbl.font == UIFont.systemFont(ofSize: 16, weight: .bold) &&
    		lbl.textAlignment == .left &&
    		lbl.text == "Title"
    
    	XCTAssertTrue(config)
    	XCTAssertEqual(lbl.superview, cell.contentView)
    }
    
    func test_artistLabel_on_the_MusicCell() {
    	let cell = MusicCell()
    	let lbl = cell.artistLabel
    	
    	let config = lbl.textColor == .black &&
    		lbl.font == UIFont.systemFont(ofSize: 10) &&
    		lbl.textAlignment == .left &&
    		lbl.text == "Artist"
    
    	XCTAssertTrue(config)
    	XCTAssertEqual(lbl.superview, cell.contentView)
    }
    
    func test_moreButton_on_the_MusicCell() {
    	let cell = MusicCell()
    	let btn = cell.moreButton
    	
    	let config = btn.image(for: .normal) == UIImage(systemName: "ellipsis") &&
    		btn.tintColor == .black
    
    	XCTAssertTrue(config)
    	XCTAssertEqual(btn.superview, cell.contentView)
    }

     

    MusicCell class 에서 Computed Property 를 선언하여 UI Elements 를 작성한 뒤 initializer 에서 addSubview 해준다.

     

    포스팅은 다 같이 했지만 항상 한 번에 하나씩 Failing Test 를 확인한 뒤 Production Code 를 작성해 Test Succeeded 를 확인하고 Test 마다 중복되는 code 들에 대해 Refactoring 하는 과정을 거치는 것이 바람직하다.

     

    class MusicCell: UITableViewCell{
    	static let identifier = "MusicCell"
    	
    	let albumImage: UIImageView = {
    		let imgView = UIImageView(image: UIImage(named: "placeholder"))
    		imgView.contentMode = .scaleAspectFit
    		imgView.clipsToBounds = true
    		return imgView
    	}()
    	
    	let titleLabel: UILabel = {
    		let lbl = UILabel()
    		lbl.textColor = .black
    		lbl.font = UIFont.systemFont(ofSize: 16, weight: .bold)
    		lbl.textAlignment = .left
    		lbl.text = "Title"
    		lbl.sizeToFit()
    		return lbl
    	}()
    	
    	let artistLabel: UILabel = {
    		let lbl = UILabel()
    		lbl.textColor = .black
    		lbl.font = UIFont.systemFont(ofSize: 10)
    		lbl.textAlignment = .left
    		lbl.text = "Artist"
    		return lbl
    	}()
    	
    	let moreButton: UIButton = {
    		let btn = UIButton()
    		btn.setImage(UIImage(systemName: "ellipsis"), for: .normal)
    		btn.tintColor = .black
    		return btn
    	}()
    	
    	required init?(coder: NSCoder) {
    		fatalError("init(coder:) has not been implemented")
    	}
    	
    	override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    		super.init(style: style, reuseIdentifier: reuseIdentifier)
    		contentView.addSubview(albumImage)
    		contentView.addSubview(titleLabel)
    		contentView.addSubview(artistLabel)
    		contentView.addSubview(moreButton)
    	}
        
    	...
    }

    UI Components 의 Constraints 작성

    이제 각 UI Component 에 대해 Layout 을 설정하면 Cell 의 원하는 위치에서 원하는 Component 를 볼 수 있다. 

     

    layoutSubviews() 함수에서 Component 의 크기와 위치에 대한 Constraint 를 작성하면 Subview 들의 Layout 에 관한 정보를 Superview 에게 알려줄 수 있다. View 의 변경 사항을 즉시 갱신하기 위해서는 layoutIfNeeded() 를 호출해야 한다. (layoutSubviews() 를 직접적으로 호출하지 말기.)

     

    func test_imgView_constraints() {
    	let cell = MusicCell()
    	let imgView = cell.albumImage
    	let contentView = cell.contentView
    	
    	cell.layoutIfNeeded()
    
    	let constraints =
    		!imgView.translatesAutoresizingMaskIntoConstraints &&
    		imgView.frame.origin.x == contentView.frame.origin.x + 15 &&
    		imgView.frame.midY == contentView.frame.midY &&
    		imgView.frame.height == contentView.frame.height - 10 &&
    		imgView.frame.width == contentView.frame.height
    	
    	XCTAssertTrue(constraints)
    }
    
    func test_titleLabel_constraints() {
    	let cell = MusicCell()
    	let imgView = cell.albumImage
    	let titleLbl = cell.titleLabel
    	let contentView = cell.contentView
    	
    	cell.layoutIfNeeded()
    	
    	let constraints =
    		!titleLbl.translatesAutoresizingMaskIntoConstraints &&
    		titleLbl.frame.origin.x == imgView.frame.maxX + 10 &&
    		titleLbl.frame.origin.y == imgView.frame.origin.y &&
    		titleLbl.frame.width == contentView.frame.width - 120
    
    	XCTAssertTrue(constraints)
    }
    
    func test_artistLabel_constraints() {
    	let cell = MusicCell()
    	let imgView = cell.albumImage
    	let titleLbl = cell.titleLabel
    	let artistLbl = cell.artistLabel
    	let contentView = cell.contentView
    	
    	cell.layoutIfNeeded()
    	
    	let constraints =
    		!artistLbl.translatesAutoresizingMaskIntoConstraints &&
    		artistLbl.frame.origin.x == imgView.frame.maxX + 10 &&
    		artistLbl.frame.origin.y == titleLbl.frame.maxY + 2 &&
    		artistLbl.frame.width == contentView.frame.width - 120
    
    	XCTAssertTrue(constraints)
    }
    
    func test_moreButton_constraints() {
    	let cell = MusicCell()
    	let moreBtn = cell.moreButton
    	let contentView = cell.contentView
    	
    	cell.layoutIfNeeded()
    	
    	let constraints =
    		!moreBtn.translatesAutoresizingMaskIntoConstraints &&
    		moreBtn.frame.maxX == contentView.frame.maxX - 20 &&
    		moreBtn.frame.midY == contentView.frame.midY
    
    	XCTAssertTrue(constraints)
    }

     

    MusicCell class 의 layoutSubviews() 함수에서 constraints 를 작성해 Test 를 통과한다.

     

    override func layoutSubviews() {
    	super.layoutSubviews()
    	
    	// Album image view constraints.
    	albumImage.translatesAutoresizingMaskIntoConstraints = false
    	let albumImageConstraints = [
    		albumImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
    		albumImage.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
    		albumImage.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: -10),
    		albumImage.widthAnchor.constraint(equalTo: contentView.heightAnchor)
    	]
    	
    	// Title label constraints.
    	titleLabel.translatesAutoresizingMaskIntoConstraints = false
    	let titleLabelConstraints = [
    		titleLabel.leadingAnchor.constraint(equalTo: albumImage.trailingAnchor, constant: 10),
    		titleLabel.topAnchor.constraint(equalTo: albumImage.topAnchor),
    		titleLabel.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -120)
    	]
    	
    	// Artist label constraints.
    	artistLabel.translatesAutoresizingMaskIntoConstraints = false
    	let artistLabelConstraints = [
    		artistLabel.leadingAnchor.constraint(equalTo: albumImage.trailingAnchor, constant: 10),
    		artistLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
    		artistLabel.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -120)
    	]
    	
    	// More button constraints.
    	moreButton.translatesAutoresizingMaskIntoConstraints = false
    	let moreButtonConstraints = [
    		moreButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
    		moreButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
    	]
    	
    	NSLayoutConstraint.activate(albumImageConstraints)
    	NSLayoutConstraint.activate(titleLabelConstraints)
    	NSLayoutConstraint.activate(artistLabelConstraints)
    	NSLayoutConstraint.activate(moreButtonConstraints)
    }

    Refactoring

    Cell 에 대한 Test class 이고 여러 Test 에서 MusicCell() 을 반복하므로 sut 변수를 MusicCell type 으로 선언한다. contentView 역시 자주 반복되므로 따로 초기화한다.

     

    class MusicCellTests: XCTestCase {
    	var sut: MusicCell!
    	var contentView: UIView!
    	
    	override func setUpWithError() throws {
    		try super.setUpWithError()
    		sut = MusicCell()
    		contentView = sut.contentView
    	}
    	
    	override func tearDownWithError() throws {
    		sut = nil
    		contentView = nil
    		try super.tearDownWithError()
    	}
    	...

     

    Custom Cell 에 Data 올리기

    Cell 의 형태가 정리되었으므로 원하는 Data 를 Cell 의 각 UI Components 에 올리자. Cell 의 내용을 Configure 하는 과정은 Data Source 에서 정의하는 cellForRowAt indexPath method 에서 진행한다. 

     

    잠깐 현재 Cell 이 어떤 모습인지 확인하기 위해 SceneDelegate class 에서 임시로 Data 를 만들어 Root ViewController 의 Data Source 로 제공한 뒤 Simulator 를 실행해보자.

     

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    	
    	guard let windowScene = (scene as? UIWindowScene) else { return }
    	
    	let window = UIWindow(windowScene: windowScene)
    	
    	let music = [
    		Music(singer: "one", album: "", title: "", duration: 0, image: "", file: "", lyrics: ""),
    		Music(singer: "two", album: "", title: "", duration: 0, image: "", file: "", lyrics: "")
    	]
    	let dataSource = PlayListDataSource(music: music)
    	
    	let vc = PlayListViewController()
    	vc.dataSource = dataSource
    	
    	window.rootViewController = vc
    	self.window = window
    	window.makeKeyAndVisible()
    }

     

    PlayListDataSourceTests 의 test_show_data_for_rows_correctly() 에서 제공한 Data 가 Cell Text 에 나타남을 확인했기 때문에 "one" 과 "two" 라는 Text 를 당연히 확인할 수 있지만, 우리가 설정한 Cell 의 형태는 다르기 때문에 겹쳐져서 보인다. 

     

    임의로 제공한 Text 와 Image View 가 겹쳐져 있다.

     

    Data 가 원하는 위치에서 보이게 하기 위해 Data Source 의 method 를 수정해야 하므로 잠시 PlayListDataSourceTests.swift 파일로 돌아간다.

     

    test_show_data_for_rows_correctly() 와 같이 임의로 Data 를 제공한 뒤 Cell 의 각 Property 가 해당 Data 를 가지는지 확인하는 Assertion 을 작성해보자.

     

    func test_configure_cell_correctly() {
    	// GIVEN data source.
    
    	// WHEN you create table view with given data source and register reusable cell,
    	let tableView = setTableView()
    	tableView.register(MusicCell.self, forCellReuseIdentifier: "MusicCell")
    	tableView.reloadData()
    
    	// THEN there should be correct data on the correct cell.
    	if let cell = tableView.dataSource?.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)) as? MusicCell {
    		XCTAssertEqual(cell.titleLabel.text, "Ziggy Stardust")
    		XCTAssertEqual(cell.artistLabel.text, "David Bowie")
    		XCTAssertEqual(cell.albumImage.image, UIImage(named: "DavidBowie"))
    	}
    }

     

    이 Test class 에서는 제공할 임시 Data 를 할당받은 PlayListDataSource 객체를 sut 로 활용한다. 이전의 Test 에서 임시로 Text 만 확인할 수 있도록 간략한 Data 를 작성했었기 때문에 이번 Test 를 위해서는 Image 이름과 Title, Artist 정보까지 가지는 Data 을 작성한다.

     

    override func setUpWithError() throws {
    	try super.setUpWithError()
    	music = [
    		Music(singer: "David Bowie", album: "The Rise and Fall of Ziggy Stardust", title: "Ziggy Stardust", duration: 220, image: "DavidBowie", file: "empty", lyrics: "some lyrics")
    	]
    	sut = PlayListDataSource(music: music)
    }

     

    Test 실패를 확인한 뒤 PlayListDataSource.swift 의 cellForRowAt  함수를 수정한다. 

     

    기본적인 UITableViewCell 의 textLabel 에 제공받은 임시 Data 의 singer property 를 대입하도록 작성되어 있었지만 이제 MusicCell 의 property 에 image, title, singer 를 대입해야 한다. 이를 위해 cell 을 MusicCell 로 Type casting 하며 초기화하고 각 property 에 적절하게 대입해준다.

     

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    	guard let cell = tableView.dequeueReusableCell(withIdentifier: "MusicCell", for: indexPath) as? MusicCell else { fatalError() }
    	cell.albumImage.image = UIImage(named: (music[indexPath.row].image))
    	cell.titleLabel.text = music[indexPath.row].title
    	cell.artistLabel.text = music[indexPath.row].singer
    	
    	return cell
    }

     

    이것으로 Test 를 모두 통과할 수 있고 test_show_data_for_rows_correctly() 는 삭제하면 된다. 실제제 실행 시 형태를 확인하기 위해 SceneDelegate.swift 의 scene 함수에 Data 를 제공해보자. 앨범 사진은 미리 Assets 에 저장해 두고 그 이름을 아래 Data 에 제공했다.

     

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    	
    	guard let windowScene = (scene as? UIWindowScene) else { return }
    	
    	let window = UIWindow(windowScene: windowScene)
    	
    	let music = [
    		Music(singer: "David Bowie", album: "The Rise and Fall of Ziggy Stardust", title: "Ziggy Stardust", duration: 220, image: "DavidBowie", file: "empty", lyrics: "some lyrics"),
    		Music(singer: "The Beatles", album: "Abbey Road", title: "Octopus's Garden", duration: 230, image: "AbbeyRoad", file: "empty", lyrics: "some lyrics")
    	]
    	let dataSource = PlayListDataSource(music: music)
    	
    	let vc = PlayListViewController()
    	vc.dataSource = dataSource
    	
    	window.rootViewController = vc
    	self.window = window
    	window.makeKeyAndVisible()
    }

     

    앨범 자켓과 제목, 가수가 원하는 위치에 잘 보인다.

     

    음악 정보를 올릴 Table View, Custom Cell 이 준비되었으므로 다음 포스팅에서 실제 음악 정보를 URL 에서 가져오는 과정을 작성해본다.