본문 바로가기

프로젝트/FastingTimer

06. 더 나아가기

더 나아가기
| 난이도 선택하기, 개선하기


강의는 끝났지만 난이도를 선택하는 부분은 빠져있다.

enum FastingPlan: String {
    case beginner = "12:12"
    case intermediate = "16:8"
    case advanced = "20:4"

    var fastingPeriod: Double {
        switch self {
        case .beginner:
            return 12
        case .intermediate:
            return 16
        case .advanced:
            return 20
        }
    }
}

beginner와 intermediate, advance를 화면에서 직접 선택할 수 있도록 추가로 구현한다.

난이도 선택하기
| 인터페이스 구성하기

현재 앱의 화면은 위와 같고, 난이도를 선택하기 위해 다른 화면으로 이동할 수도 있지만,
별도의 버튼이나 새로운 화면을 구현하는 대신 간단하게 FastingPlan을 표시하는 부분을 변경해 구현한다.

Menu

 

Apple Developer Documentation

 

developer.apple.com

iOS14부터는 Button을 나열해 표현할 수 있는 menu라는 것을 제공한다.
현재 앱의 타깃이 iOS14부터이기 때문에 사용하는데 문제없다.

enum FastingPlan: String, CaseIterable {
    case beginner = "12:12"
    case intermediate = "16:8"
    case advanced = "20:4"

    var title: String {
        switch self {
        case .beginner:
            return "Beginner"
        case .intermediate:
            return "Intermediate"
        case .advanced:
            return "Advanced"
        }
    }

    var fastingPeriod: Double {
        switch self {
        case .beginner:
            return 12
        case .intermediate:
            return 16
        case .advanced:
            return 20
        }
    }
}

FastingPlan을 위와 같이 변경한다.
CaseIterable 프로토콜은 이후 Menu의 버튼들을 구성할 때 ForEach 열거를 사용해 간편하게 구현할 수 있도록 한다.
현재 선택된 난이도가 어떤 수준인지 조금 더 직관적으로 표시하기 위해 문자열을 반환하는 title Compute 변수를 추가로 구현했다.

before

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Text(fastingManager.fastingPlan.rawValue)
            .fontWeight(.bold)
            .padding(.horizontal, 24)
            .padding(.vertical, 8)
            .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
            .cornerRadius(25)
    }
}

구현할 FastingPlan 인디케이터는 ContentView의 header에 구현돼 있다.

after

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {

        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
    }
}

기존의 구현은 Menu의 label로 이동한다.
Menu의 첫 번째 파라미터에는 Menu를 구성할 Button들을 주로 전달한다.

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {
            Button {

            } label: {
                Text("Beginner")
            }

            Button {

            } label: {
                Text("Intermediate")
            }

            Button {

            } label: {
                Text("Advance")
            }
        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
    }
}

ForEach 열거를 사용하지 않는 일반 구현은 위와 같다.
구성하려는 Menu의 수만큼 Button을 구성하고, 각각의 Action도 별도로 구현해야 한다.
이는 Menu의 수가 적다면 고려해 볼 수 있을만한 간단한 방식이다.

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {
            ForEach(FastingPlan.allCases, id: \.rawValue) { value in
                Button {

                } label: {
                    Text("\(value.title)")
                }
            }
        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
    }
}

하지만 지금처럼 내용과 역할이 일관돼 있다면 ForEach를 사용하는 것이 유지보수 측면이나 가독성 면에서 훨씬 뛰어나다.
Menu에서 선택한 항목이 CompletionHanler의 value라는 이름의 변수로 바인딩되고, 이를 사용하기만 하면 된다.

두 방법 모두 구현 방식만 다를 뿐 같은 UI를 가지게 된다.

난이도 선택하기
| 동작 구현

FastingTimer의 전체를 관통하는 FastingPlan은 실제로는 FastingManager에 종속된 private(set) 형식의 fastingPlan 변수다.
해당 속성은 외부의 '읽기'만 가능하지 '쓰기'는 불가능하게 되므로, 지금의 구성대로라면 내부에서 이를 변경해 줄 메서드를 구현해야 한다.

func setPlan(selected: FastingPlan) {
    fastingPlan = selected
}

FastingManaer에 새로 구현한 serPlan 메서드는
선택한 난이도를 전달받아 FastingManager 내에서 fastingPlan을 변경하는 메서드다.

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {
            ForEach(FastingPlan.allCases, id: \.rawValue) { value in
                Button {
                    fastingManager.setPlan(selected: value)
                } label: {
                    Text("\(value.title)")
                }
            }
        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
    }
}

해당 메서드를 Menu의 버튼에서 호출만 해 주면 간단하게 동작한다.

만약 이러한 아키텍처를 따르지 않고자 않다면 형식을 변경하는 것도 방법이다.

class FastingManager: ObservableObject {
    @Published private(set) var fastingState: FastingState = .notStarted
    @Published var fastingPlan: FastingPlan
    @Published private(set) var startTime: Date {
        didSet {
            print("startTime: ")
            print(DateFormat.string(from: startTime))

            if fastingState == .fasting {
                endTime = startTime.addingTimeInterval(fastingTime)

fastingPlan의 형식을 외부의 읽기와 쓰기가 모두 가능하도록 일반 변수로 만드는 방법이다.

Menu {
    ForEach(FastingPlan.allCases, id: \.rawValue) { value in
        Button {
            fastingManager.fastingPlan = value
        } label: {
            Text("\(value.title)")
        }
    }
} label: {
    Text(fastingManager.fastingPlan.rawValue)
        .fontWeight(.semibold)
        .padding(.horizontal, 24)
        .padding(.vertical, 8)
        .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
        .cornerRadius(20)
}

이 경우에는 별도의 메서드의 구현 없이 직접 속성을 변경하면 된다.

개선
| 인터페이스 수정 #1

기존의 FastingPlan을 표시하던 Text를 Menu로 변경하면서 앱의 기본 Tint 색으로 표시되는 차이가 생겼다.

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {
            ForEach(FastingPlan.allCases, id: \.rawValue) { value in
                Button {
                    fastingManager.setPlan(selected: value)
                } label: {
                    Text("\(value.title)")
                }
            }
        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
        .foregroundColor(.primary)
    }
}

menu의 foregroudColor을 'primary'로 설정한다.

ColorScheme에 따라 자동으로 색이 전환되는 이전의 디자인을 유지할 수 있다.

개선
| 인터페이스 수정 #2

menu가 조금 더 명확한 내용을 담도록 Label을 수정한다.

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {
            ForEach(FastingPlan.allCases, id: \.rawValue) { value in
                Button {
                    fastingManager.setPlan(selected: value)
                } label: {
                    Text("\(value.title) \(value.rawValue)")
                }
            }
        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
        .foregroundColor(.primary)
    }
}

난이도의 이름 옆에 Period를 함께 표시하도록 변경했다.

개선
| 인터페이스 수정 #3

FastingTimer를 디자인할 때 DummyData로 Intermediate를 사용했기 때문에 다른 난이도를 선택하는 경우 Period가 정상적으로 표시되지 않는 문제가 존재한다.

Beginner를 선택했을 때가 대표적인데, 최근 새로 알게 된 방법으로 연습 삼아 해결해 본다.

var header: some View {
    VStack(spacing: 40) {
        // MARK: Title
        Text(title)
            .font(.headline)
            .foregroundColor(Color(#colorLiteral(red: 0.8567120433, green: 0.5915268064, blue: 1, alpha: 1)))

        // MARK: Fasting Plan
        Menu {
            ForEach(FastingPlan.allCases, id: \.rawValue) { value in
                Button {
                    fastingManager.setPlan(selected: value)
                } label: {
                    Text("\(value.title) \(value.rawValue)")
                }
            }
        } label: {
            Text(fastingManager.fastingPlan.rawValue)
                .fontWeight(.bold)
                .padding(.horizontal, 24)
                .padding(.vertical, 8)
                .background(BlurEffect().blurEffectStyle(.systemThickMaterial))
                .cornerRadius(25)
        }
        .foregroundColor(.primary)
        .minimumScaleFactor(0.1)
    }
}

minimumScaleFactor를 사용하는 방법이다.
Menu의 Title 자체를 조절할 수도 있겠지만, frame 사이즈에 맞게 내용물의 배율을 동적으로 조절하는 방식으로 동작한다.

'프로젝트 > FastingTimer' 카테고리의 다른 글

05. 기능 구현 #3  (0) 2023.02.20
04. 기능 구현 #2  (0) 2023.02.17
03. 기능 구현 #1  (0) 2023.02.16
02. 인터페이스 디자인 #2  (0) 2023.02.16
01. 인터페이스 디자인 #1  (0) 2023.02.14