포스트

HP Trivia (6)

Change Book Status

이번 강의에서는 책(Book)의 상태를 변경할 수 있는 기능을 구현한다.
책 상태는 .active, .inactive, .locked 세 가지로 구분되며, 사용자의 탭 제스처에 따라 상태를 변경하고 뷰에 반영한다.

상태 변경 시도와 오류 발생

처음에는 아래처럼 .onTapGesture를 통해 상태를 바꾸려 했지만, 다음과 같은 오류가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ZStack(alignment: .bottomTrailing) {
    Image(book.image)
        .resizable()
        .scaledToFit()
        .shadow(radius: 7)
    
    Image(systemName: "checkmark.circle.fill")
        .font(.largeTitle)
        .imageScale(.large)
        .foregroundStyle(.green)
        .shadow(radius: 1)
        .padding(3)
}
.onTapGesture {
    book.status = .inactive // 오류 발생
}

에러: Cannot assign to property: 'book' is a 'let' constant

이는 ForEach { book in } 구문에서 book이 복사된 상수이기 때문이다.
직접 값을 바꾸는 것이 불가능하므로, BookQuestions 클래스 내부에서 처리해야 한다.

BookQuestions에 상태 변경 메서드 추가

1
2
3
func changeStatus(of id: Int, to status: BookStatus) {
    books[id - 1].status = status
}

배열은 0부터 시작하므로, 책 ID에서 1을 빼준다.

View에서 상태 변경 적용

1
2
3
.onTapGesture {
    game.bookQuestions.changeStatus(of: book.id, to: .inactive)
}

탭 시 BookQuestions 내부의 books 배열이 업데이트된다.

Image

Inactive 상태 표현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
else if book.status == .inactive {
    ZStack(alignment: .bottomTrailing) {
        Image(book.image)
            .resizable()
            .scaledToFit()
            .shadow(radius: 7)
            .overlay {
                Rectangle().opacity(0.33)
            }
        
        Image(systemName: "circle")
            .font(.largeTitle)
            .imageScale(.large)
            .foregroundStyle(.green.opacity(0.5))
            .shadow(radius: 1)
            .padding(3)
    }
    .onTapGesture {
        game.bookQuestions.changeStatus(of: book.id, to: .active)
    }
}

이제 다시 테스트를 해보면

Image

overlay를 통해 inActive 상태의 표현을 좀 더 부각시켰다.

Locked 상태 표현 및 Alert 표시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ZStack {
    Image(book.image)
        .resizable()
        .scaledToFit()
        .shadow(radius: 7)
        .overlay {
            Rectangle().opacity(0.75)
        }

    Image(systemName: "lock.fill")
        .font(.largeTitle)
        .imageScale(.large)
        .shadow(color: .white, radius: 2)
}
.onTapGesture {
    showTempAlert.toggle()
    game.bookQuestions.changeStatus(of: book.id, to: .active)
}

여기도 역시 overlay를 통해 lock 상태의 표현을 좀 더 부각시켰다.

추가로 @State private var showTempAlert = false를 선언하고, 아래처럼 ZStack 바깥에 alert를 붙여준다.

1
2
3
4
5
6
7
8
var body: some View {
    ZStack{ 
        // 생략
    }
    .alert("You purchased a new question pack. Yay!", isPresented: $showTempAlert) {
        // 비어있는 alert 버튼
    }
}

Image

Disable Dismiss

disable을 하는 이유는 모든 책이 inactive 또는 locked일때 게임이 진행이 되면 안되기에 하는 일종의 예외 처리이다.

우선 Computed Property를 하나 만든다.

1
2
3
4
5
6
7
8
9
var activeBooks: Bool {
    for book in game.bookQuestions.books {
        if book.status == .active {
            return true
        }
    }
    
    return false
}

이 property는 책이 한권이라도 active면 true 그게 아니라면 false를 리턴하도록 한다.

이후 scrollview 하단에 다음과 같이 if문을 하나 추가한다.

1
2
3
4
5
6
7
ScrollView {
// 생략
}

if !activeBooks { //new
    Text("You must select at least 1 book.")
}

책이 한권이라도 active아닐경우 Text를 Scrollview하단에 Text를 띄우기 위함이다.

Image

그리고 Button에도 Modifier를 추가한다.

1
2
3
4
5
6
7
8
9
Button("Done") {
    dismiss()
}
.font(.largeTitle)
.padding()
.buttonStyle(.borderedProminent)
.tint(.brown.mix(with: .black, by: 0.2))
.foregroundStyle(.white)
.disabled(!activeBooks) // new

바로 false를 감지하는순간 버튼을 disabled하게 만드는것이다.

이러면 한권이라도 Active가 아닐경우 버튼이 비활성화되어 눌리지 않는다.

하지만 sheet로 올린 View라 아래로 드래그하면 내려가기에 이것또한 방지하기위해 Modifier 를 추가.

1
2
3
4
5
6
7
ZStack {
    // 생략
}
.interactiveDismissDisabled(!activeBooks) // new
.alert("You purchased a new question pack. Yay!", isPresented: $showTempAlert) {
    // blank
}

interactiveDismissDisabled의 경우 이전글에서 언급을 한적이 있으니 참고

Single Responsibility Principle 적용하기

이전글에서 작성했었기에 별다른 언급은 패스

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
ScrollView {
    LazyVGrid(columns: [GridItem(), GridItem()]) {
        ForEach(game.bookQuestions.books) { book in
            if book.status == .active {
                ActiveBookView(book: book)
                    .onTapGesture {
                        game.bookQuestions.changeStatus(of: book.id, to: .inactive)
                    }
                
            } else if book.status == .inactive {
                InactiveBookViews(book: book)
                    .onTapGesture {
                        game.bookQuestions.changeStatus(of: book.id, to: .active)
                    }
            } else {
                LockedBookView(book: book)
                    .onTapGesture {
                        showTempAlert.toggle()
                        
                        game.bookQuestions.changeStatus(of: book.id, to: .active)
                    }
            }
        }
    }
}               

각각 해당하는 View를 만들어서 옮겨준다.

하나만 예시를 들면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct LockedBookView: View {
    @State var book: Book // new
    
    var body: some View {
        ZStack {
            Image(book.image)
                .resizable()
                .scaledToFit()
                .shadow(radius: 7)
                .overlay {
                    Rectangle().opacity(0.75)
                }
            
            Image(systemName: "lock.fill")
                .font(.largeTitle)
                .imageScale(.large)
                .shadow(color: .white, radius: 2)
        }
    }
}

@State var book: Book 변수를 만들어 준다.

물론 book은 의존성 주입에 따라 기존 SelectBooksView에서 book을 그대로 전달받는다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.