Week 3: Assignment

sun·2021년 10월 9일
0

하루 날 잡고 하려면 절대 안 쓸 것 같아서 틈틈이 업데이트 해 볼 생각이다

깃허브 링크


# 기본 구조


# 고난 1: ScrollView 근데 이제 스크롤이 안되는...

  • 화면 크기와 카드 개수에 따라 카드 크기가 적절히 조정되도록 반응형 인터페이스를 구현하라는 요구사항이 었었다. 구현 자체는 5강과 똑같은 방식으로 금방 했는데 문제는 이를 구현한 AspectVGrid 가 스크롤이 되도록 ScrollView 에 넣으면 아래로 스크롤이 안됐다..
import SwiftUI

struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
    let items: [Item]
    let aspectRatio: CGFloat
    let content: (Item) -> ItemView
    
    init(items: [Item], aspectRatio: CGFloat, @ViewBuilder content: @escaping (Item) -> ItemView) {
        self.items = items
        self.aspectRatio = aspectRatio
        self.content = content
    }
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                    let width: CGFloat = max(widthThatBestFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio), 80)
                LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
                    ForEach(items) {
                        content($0)
                            .aspectRatio(aspectRatio, contentMode: .fit)
                    }
                }
                Spacer(minLength: 0)
            }
        }
    }
    
    private func adaptiveGridItem(width: CGFloat) -> GridItem {
        var gridItem = GridItem(.adaptive(minimum: width))
        gridItem.spacing = 0
        return gridItem
    }
    
    private func widthThatBestFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
        var columnCount = 1
        var rowCount = itemCount
        
        repeat {
            let itemWidth = size.width / CGFloat(columnCount)
            let itemHeight = itemWidth / itemAspectRatio
            if CGFloat(rowCount) * itemHeight < size.height {
                break
            }
            columnCount += 1
            rowCount = Int(ceil(Double(itemCount) / Double(columnCount)))
            
        } while columnCount < itemCount
        
        if columnCount > itemCount {
            columnCount = itemCount
        }
        return floor(size.width / CGFloat(columnCount))
    }
}
  • 구글링을 아무리 해봐도 명쾌한 해답은 안보이고 그냥 하드코딩한 숫자들(비율, 개수 등등)이 문제의 원인일 것이다 하는 답변들 뿐이었는데 코드를 아무리 봐도 문제가 생길만한 곳이 없었다....
  • 혹시나 싶어서 AspectVGrid 를 지우고 LazyVGrid 에 고정된 카드 크기로 CardView 를 만들어서 ScrollView 에 넣어봤더니 멀쩡하게 스크롤이 됐다...그래서 일단 문제는 AspectVGrid 에 있구나 싶었다
  • 공식문서도 읽어보고 이것저것 해봐도 해결이 안돼서 일단 미뤄두고 다음날 다시 봤는데 문득 어제 그냥 ScrollViewLazyVGrid 를 때려박았을 때는 스크롤이 됐으니까 AspectVGrid 에서 View 를 반환하기 전에 만들어진 그리드를 ScrollView 에 넣어서 아예 AspectVGrid 자체를 스크롤이 되는 뷰로 만들면 어떨까 하는 생각이 들었다.
    var body: some View {
        GeometryReader { geometry in
            VStack {
                ScrollView {
                    let width: CGFloat = max(widthThatBestFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio), 80)
                    LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
                        ForEach(items) {
                            content($0)
                                .aspectRatio(aspectRatio, contentMode: .fit)
                        }
                    }
                }
                Spacer(minLength: 0)
            }
        }
    }
    
  • 해결 : 그랬더니 스크롤이 됐다!!!!!!!! 아무튼 ScrollView 안에 AspectVGrid 를 넣으면 스크롤이 안되는데 그 반대는 될까에 대해 스스로 내린 결론은 다음과 같다!
    • View 는 기본적으로 스크롤 기능을 지원하지 않기 때문에 최대로 보여줄 수 있는 영역이 인터페이스 크기로 제한된다

    • 따라서 전자의 경우, CardView 들이 잔뜩 들어있는 LazyVGrid 는 최대 크기가 인터페이스 크기로 제한되는 AspectVGrid 안에 담기게 되면서 이 시점에 View 크기가 인터페이스 크기로 제한되고, 이에 따라 AspectVGrid 를 담고 있는 ScrollView 는 인터페이스 크기만큼이 자신이 담고 있는 전부이므로 스크롤을 할 필요가 없다고 여긴다

    • 반면, 후자의 경우 LazyVGrid 를 바로 ScrollView 안에 넣음으로써 카드 개수에 따라 영역이 인터페이스를 벗어나더라도 스크롤을 통해 볼 수 있게 한다!

    • 결국 내가 View 에 대한 이해가 부족해서 발생했던 문제였고, 덕분에 View 에 대해 다시 한 번 공부할 수 있었다.

# 고난 2: 짜잔 이 게임은 스크롤하면 카드 뷰가 랜덤으로 변합니다

  • 솔직히 이건 원인과 해결책은 알겠는데 사실 문제가 발생하는 정확한 인과관계는 아직도 모르겠다...고 생각했는데 포스팅 올리려고 검색해보다가 심지어 더 좋은 해결책을 발견했다
  • 문제 상황은 게임을 할 때 일부 카드가 자꾸 중복으로 뜨는 거였다. 처음에는 카드 변수들과 관련된 변수/코드에서 문제가 생기나 싶어서 살펴봤는데(새로운 카드를 추가할 때 인덱스를 중복으로 주고 있나 싶었다) 딱히 문제가 없었다.
  • 그런데도 계속 게임을 하다보면 전체도 아니고 일부 카드가 자꾸 바뀌거나 중복으로 나오는 문제가 발생했다.
  • 도대체 어디서 문제가 생기는걸까 싶었는데 아예 카드를 다 띄워놓고 스크롤을 하면서 확인하다 보니 콘솔에
    count (3) != its initial count (2). 'ForEach(_:content:)/' should only be used for *constant* data. Instead conform data to 'Identifiable' or use 'ForEach(_:id:content:)' and provide an explicit 'id'
    라는 메시지가 계속 떴다. 자세히 보니 스크롤 시에 모양이 1개였던 카드의 모양이 2개, 3개가 되거나 그 반대 현상이 나타나고 있었다.
  • 그걸 보고 View 파트의 코드에서 문제가 발생하고 있구나 싶었다. 나는 CardView 에서 .symbol.numberOfShapes 변수로 모양의 개수를 확인하고 switch 문으로 분기한 다음 ForEach 문을 돌면서 해당 개수만큼 문양을 생성하고 있었는데 여기 어딘가에서 계속 문제가 발생하는 것 같았다.
    • 검색해보니 ForEach 문을 쓰려면 순회하는 데이터/범위가 고정되어 있거나, 가변적인 경우 각 요소가 식별 가능한 id를 갖고 있어야 한다!
    • 나는 범위가 가변적이므로 각 요소에게 고유한 id를 부여했어야 했는데 이를 생략해 문제가 발생하고 있었다.
struct CardView: View {
    let card: SquareSetGame.Card
    
    var body: some View {
        ZStack {
        	...
                VStack {
                    switch card.content.shape {
                    case .roundedRectangle:
                        ForEach(0..<card.content.numberOfShapes) { _ in
                            createRoundedRectangleView(by: card.content)
                        }
                    case .square:
                        ForEach(0..<card.content.numberOfShapes) { _ in
                            createSquareView(by: card.content)
                        }
                    case .diamond:
                        ForEach(0..<card.content.numberOfShapes) { _ in
                            createDiamondView(by: card.content)
                        }
                    }
                    ...
    }
  • 해결 : 범위 내의 각 숫자는 고유하므로, id로 자신의 값 을 지정해줬더니 해당 문제가 해결됐다!
                VStack {
                    ForEach(0..<card.symbol.numberOfShapes, id: \.self) { _ in
                        createSymbol(for: card)
                    }
                }

# 고난 3: 줄무늬..그거 뭔데

  • 일단 완성본! {height=500px width=400px}

  • 패턴 만드는 법, 무늬 만드는 법, 줄무늬 그리는 법 등으로 엄청 검색해봤는데 지금 소개할 방법이 가장 쉬워서 이 방법으로 했다!

  • 사실 줄무늬를 만들려면 엄청 복잡할 줄 알았는데 생각보다 간단했다! 요약하면 VStack 안에 색깔별로 줄을 쌓으면 줄무늬 뚝딱 완성! 좀 더 자세한 과정은 다음과 같다.

  1. VStack 안에 ForEach 문 을 이용해서 줄무늬 개수만큼 색깔을 선언을 반복하면 가로 줄무늬가 생긴다

    • Color 자체가 적절한 문맥에서는 해당 View 처럼 작동해 해당 색깔의 직사각형을 나타내기 때문! ( 8강 3분 부터 참고 )

  1. 이 상태에서 VStackmask(//someShape) 메서드를 적용하면 원하는 모양에 맞게 줄무늬가 잘린다!

  1. 모양에 맞게 잘린 줄무늬 위에 overlaying(//someshape) 로 모양 테두리를 얹어주면 맨 위의 첫 번째 사진처럼 마치 해당 모양이 줄무늬로 채워진 것과 같은 뷰가 완성된다!
struct StripeView<SymbolShape>: View where SymbolShape: Shape {
    let numberOfStripes: Int = 8
    let borderLineWidth: CGFloat = 1.3
    let shape: SymbolShape
    let color: Color
    
    var body: some View {
        VStack(spacing: 0.5) {
            ForEach(0..<numberOfStripes) { _ in
                Color.white
                color
            }
            Color.white
        }.mask(shape)
        .overlay(shape.stroke(color, lineWidth: borderLineWidth))
    }
}

# 고난 4: 지렁이...그거 어떻게 구현하는 건데...

  • 이건 결국 킹짱 스택오버플로우에서 누군가가 아주 친절하게 구현해 준 지렁이를 들고와서 썼다...!

# 고난 5: 줄무늬랑 지렁이 겨우 만들었는데 Shape은 반환이 안될 때

  • 줄무늬랑 지렁이 모양을 겨우 다 만들고 나서 카드 모양 자체를 구현하기 위해서 인자로 모양을 받아서 완성된 CardView 를 반환하는 함수를 만드려고 했는데 계속 이런 에러가 떴다...Protocol 'Shape' can only be used as a generic constraint because it has Self or associated type requirements...수업 때도 언급된 내용이긴 했는데 구글링 결과 Shape 프로토콜 자체가 generic 을 쓰기 때문에 타입이 모호해서 인자로 쓸 수 없었던 것...간신히 구현해놨는데 갈아엎어야되나 싶어서 여기서 눈물이 날 뻔 했으나...
  • 다행히도 갓네릭을 이용해서 인자 shape 의 타입을 SymbolShape 이라는 제네릭으로 선언하고 해당 타입이 shape 프로토콜 에 순응하도록 했더니 해결됐다...! 인자로 보내는 방법이 절대 없을 줄 알고 안그래도 더러운 코드 더 더러워질 것 같아서 눈 앞이 캄캄했는데...다행히 해결...!
@ViewBuilder
    private func createSymbolView<SymbolShape>(of symbol: SunSetGame.Card.CardContent, shape: SymbolShape) -> some View where SymbolShape: Shape {
        
        switch symbol.pattern {
        case .filled:
            shape.fill().foregroundColor(symbol.color.getColor())
                .aspectRatio(DrawingConstants.symbolAspectRatio, contentMode: .fit).opacity(DrawingConstants.symbolOpacity)
            
        case .shaded:
            StripeView(shape: shape, color: symbol.color.getColor())
                .aspectRatio(DrawingConstants.symbolAspectRatio, contentMode: .fit).opacity(DrawingConstants.symbolOpacity)
            
        case .stroked:
            shape.stroke(lineWidth: DrawingConstants.defaultLineWidth).foregroundColor(symbol.color.getColor())
                .aspectRatio(DrawingConstants.symbolAspectRatio, contentMode: .fit).opacity(DrawingConstants.symbolOpacity)
        }

# 고민 1: 카드 관리 -인덱스로 선언한 슬픈 짐승-

  • 수업+이전 과제들에서 만들었던 MemorizeCards 배열 이 게임 시작부터 끝까지 하나만 필요하고, 순서도 그대로 유지되어서 그냥 모든 과정을 인덱스로 접근해도 문제가 없었다...그래서 처음에 Set 도 선택된 카드를 위치 인덱스만 배열에 저장하는 방식, 즉 이렇게 private var chosenCards = [Int]() 짰으나...

  • Set 의 경우 짝을 맞춘 카드를 계속 화면에서 제거하고 또 덱에서 새로운 카드를 추가해야 하는데 이때 채워넣기 위해 제거했던 카드들을 위치 인덱스로 기억했더니 카드를 맞추고 제거한 뒤에는 남은 카드들의 인덱스가 땡겨져서 엉뚱한 카드가 선택된 상태로 남아있는 문제와 1-2 인덱스 정도 차이나는 잘못된 위치에 카드가 채워지는 문제가 있었다...

  • 결국 선택된 카드도 private var chosenCards = [Card]() 와 같이 카드 자체를 기억하고, 짝이 맞았을 때 playingCards 배열 에서 id 가 같은 카드를 찾아 제거하고, 인덱스를 기억할 필요 없이, 카드를 바로 교체해야 하는 경우 dealThreeCards() 함수에서 세트를 제거하면서 동시에 바로 새로운 카드로 교체하는 방식으로 바꿔줬다...!

struct SetGame<CardSymbolShape, CardSymbolColor, CardSymbolPattern, NumberOfShapes> where CardSymbolShape: Hashable, CardSymbolColor: Hashable, CardSymbolPattern: Hashable {
    ...
    private var chosenCards = [Card]()
    ...

    mutating func dealOneCard(at index: Int) {
        if numberOfPlayedCards < totalNumberOfCards {
            let symbol = createCardSymbol(numberOfPlayedCards)
            playingCards.insert(Card(symbol: symbol, id: numberOfPlayedCards), at: index)
            numberOfPlayedCards += 1
        }
    }
    
    mutating func dealThreeCards() {
        cheat() // should change this..not clear to others
        if remainingSet != nil { score -= 3 }
        turnOffCheat()
        
        switch chosenCards.count {
        case 3:
            if playingCards.first(where: {$0 == chosenCards.first})!.isMatched {
                chosenCards.forEach { card in
                    if let matchedIndex = playingCards.firstIndex(of: card) {
                        playingCards.remove(at: matchedIndex)
                        dealOneCard(at: matchedIndex)
                    }
                }
                chosenCards = []
            }
            else {
                chosenCards.forEach { card in
                    if let failedMatchIndex = playingCards.firstIndex(of: card) {
                        playingCards[failedMatchIndex].isChosen = false
                        playingCards[failedMatchIndex].isNotMatched = false
                    }
                }
                chosenCards = []
                fallthrough
            }
        default:
            for _ in 0..<3 {
                dealOneCard(at: playingCards.endIndex)
            }
        }
        if numberOfPlayedCards == totalNumberOfCards {
            isEndOfGame = checkEndOfGame(in: playingCards)
        }
    }
    ...
}

# 고민 : 치팅보다 힘든 치팅함수 구성

  • 변수가 낭비되는 게 싫었다 이 포스팅을 미루고 미뤘더니..결국 무슨 내용이었는지 기억을 못해서 업데이트를 못하는 슬픈 참사 발생...다음 번에는 이런 장기 포스팅 계획이 있다면 최대 3일까지의 기한을 정하고 마지막 3일차에는 무조건 포스팅하는 방식으로 해서 이런 불상사를 막아야겠다...

# 고민 : 보다 나은 View를 위하여...

더 이상 카드를 추가할 수 없을 때 카드 추가 버튼을 어떻게 처리할까?

  • 과제 지시 사항에 덱에 있던 카드가 다 소진돼서 더 이상 카드를 추가할 수 없을 때 카드 추가 버튼을 비활성화 하라는 사항이 있었다.

  • 처음에는 위에 사진처럼 그냥 카드 추가 버튼이 사라지게 했다! 왜냐하면 이게 가장 직관적으로 더 이상 카드를 추가할 수 없다는 걸 보여준다고 생각했기 때문에...! (아니 근데 이거 사진 크기 못 줄이나...??????)

  • 그런데 빠르게 카드추가를 연타하다 보면 실수로 카드가 소진된 순간 카드 추가 버튼이 사라지면서 바로 옆의 restart 버튼 의 위치가 이동해서 의도치 않았는데 새 게임을 시작하게 되는 불편 사항이 있었다...그래서 결국 오른쪽 사진처럼 카드가 다 소진되면 버튼이 회색으로 바뀌고 눌리지 않도록 변경했다!

게임이 끝났을 때는 어떻게 화면을 나타낼까?

  • 이것도 되게 고민을 많이 했는데 뭐가 가장 깔끔하고 직관적일까에 대해서...결국 그냥 게임이 끝나면 별도의 View 가 나타나도록 했다...근데 지금 보니까 다음 버전에서는 Cheat 버튼 도 회색 처리하거나 아님 Cheat 버튼Deal 3 Cards 버튼이 둘 다 사라지게 하면 좋을 것 같다...화면 전환 시에 .transition 으로 애니메이션도 추가하면 좋을 것 같고...!

# 고민 : 함수...어디까지 쪼개봤니...

  • 뭔가 하나의 함수는 하나의 기능만 하도록, 그리고 함수명이 해당 함수의 기능을 나타내도록 최대한 많이 쪼갠다고 쪼갰는데 쪼개다보니 또 어느 정도까지 쪼개야 하는 건지 정말 고민이 많이 됐다...특히 choose() 함수 의 경우 경우의 수도 많고, 각 경우의 수마다 다른 결과? 기능?을 하게 되니까 어디까지가 이 단일 함수가 가지고 있어도 되고, 어디부터는 다른 함수로 분리해야 될 지 정말 고민이 많이 됐다...결국에는 쪼개는 것도 힘들어서 너무나도 길지만 그냥 현 상태로 두기는 했는데...완강하고 다시 한번 Set 을 처음부터 짜 볼 생각이라...(코드 리뷰 받고 싶음...!) 그때 가서 새로운 포스팅이나 현재 포스팅에 한번 지금 코드랑 그 때 코드를 비교해서 포스팅해 볼 생각이다...!

☀️ 느낀점

  • 미루고 미루다가 결국 이 포스팅을 시작한 지 거의 3주...? 만에 얼렁뚱땅 마무리했는데 앞에서도 말했지만 다음부터는 무조건 3일 기한으로 잡고 써야겠다...내 자신...믿을 수 없네...

  • 사실 올 여름부터 Set 에 엄청 꽂혀서 온라인으로 친구들이랑도 엄청하고 혼자서도 엄청해서 처음에는 과제가 Set 구현이래서 운명인가!? 싶었는데 만들면서 계속 고통받고, 버그없이 잘 작동하나 확인하느라 너무 많이 해서 이제 질려버렸다...

  • 아무튼 처음으로 혼자 만든 그럴듯한 앱이라서 매우 뿌듯했다...! 완강하고 꼭 다시 짜봐야지
profile
☀️

0개의 댓글

관련 채용 정보