[SwiftUI] 미니 프로젝트를 진행하며 알게된 것 정리

728x90

SwiftUI로 Nanki (단어장 앱)을 만들어보면서 알게된 점을 총 정리해서 작성했다.

생각보다 간단하게 처리하고 사용할 수 있는게 많아서.. SwiftUI에 적응하기 위해서는 UI를 다양하게 많이! 작성해봐야 할듯.

[GitHub - JustHm/Nanki

Contribute to JustHm/Nanki development by creating an account on GitHub.

github.com](https://github.com/JustHm/Nanki)

UI 관련

List

List Style

List{
    ...
}
.listStyle(.sidebar)

리스트의 기본 스타일은 insetGrouped 로 지정되어있다.

그 외에도 여러개가 있는데, sidebar 를 적어놓은 이유는 섹션헤더를 접었다 폈다 하고싶으면 그냥 이거로 바꿔주면 된다.

추가로 sidebar 스타일은 iPad 에서 볼때는 정말 사이드바처럼 왼쪽에 따로 표시된다. (아이폰은 화면이 작으니 그냥 한 화면으로 보임)

List Inset & cell background color modify

List {
    Section {
    }
    .listRowBackground(Color.clear)
    .listRowInsets(EdgeInsets())

listRowBackground() 메서드에서 색을 변경할 수 있다. insetGrouped 상태일때 셀이 아닌거처럼 보이게 하기 유용함

listRowInsets() 메서드를 사용해 인셋을 변경할 수 있다. 아예 없애서 사용할수도 있다.

ForEach with Enumerated

ForEach(Array(list.wordList.enumerated()), id: \.offset) { item in
    Button {  ...
}

ForEach 문에서 index를 함께 사용하고 싶다면 Range로 접근하게 하는법도 방법이지만,

다른 방법으로 enumerated() 를 사용하면 인덱스와 아이템을 튜플로 반환받아 사용할 수 있다.

하지만 그냥 넘기면 안되고 Array로 감싸 넘겨줘야한다.

Disclosure Group Cell

DisclosureGroup("단어 퀴즈") {
    NavigationLink {
        QuizView(list: list.wordList)
    } label: {
        Text("주관식 퀴즈")
    }
    .disabled(list.wordList.isEmpty)
    NavigationLink {
        MultipleChoiceQuizView(list: list.wordList)
    } label: {
        Text("객관식 퀴즈")
    }
    .disabled(list.wordList.count < 4)
    NavigationLink {
        PairQuizView(list: list)
    } label: {
        Text("짝 맞추기 게임")
    }
    .disabled(list.wordList.count < 4)
}

셀을 클릭했을 때 접었다 폈다 하면서 내부 데이터를 보여주는 그룹!!

Section Header를 접었다 폈다 하는것과 다르게 셀을 접었다가 폈다 한다.

Text

Text(option)
    .foregroundColor(.primary)  // 글자색 검정으로 통일
    .frame(maxWidth: .infinity)
    .padding()
    .background(
        RoundedRectangle(cornerRadius: 10)
            .stroke(getOptionColor(option), lineWidth: 2)
    )

외곽선을 만들어 텍스트뷰를 표시하는방법

RoundedRectanglebackground 에 사용해서 그려주면 된다. stroke 는 외곽선!!

Button

Hover Button

.overlay(alignment: .bottomTrailing) {
    if isCanEdit {
        Button(action: {addsheet.toggle()}) {
            Image(systemName: "plus")
                .resizable()
                .scaledToFit()
                .frame(width: 24, height: 24)
                .foregroundColor(.white)
                .padding()
        }
        .background(Circle().fill(Color.blue))
        .frame(width: 64, height: 64)
        .offset(x:-15)
    }
}

뷰에 overlay 를 사용해 버튼을 만들어줬다.

버튼의 경우 background 메서드를 사용해 모양을 지정해주고 라벨로는 이미지를 사용했다.

크기는 background 아래 frame 으로 기본크기를 지정하고 버튼라벨은 그에 맞게 조정해주면 된다.

ContextMenu

.contextMenu {
    if isCustom {
        Button("Delete", systemImage: "trash", role: .destructive) {
            withAnimation { store.deleteWordSet(id: item.id) }
        }
    }
}

버튼이나 셀, 또는 카드를 꾹 누르면 나오던 메뉴바들이 있는데, contextMenu 메서드를 사용해서 구현할 수 있다.

하지만 여기서 직접 NavigationLink를 걸 수 없다 (이동안됌.) 새로운 방식을 알아보거나 플래그를 두면 가능할지도.

TextField

@FocusState private var wordFocus: Bool
@FocusState private var meaningFocus: Bool

Section("단어") {
    TextField("Input Word", text: $wordInput)
        .onSubmit { meaningFocus = true }
        .focused($wordFocus)
        .submitLabel(.done)
}
Section("의미") {
    TextField("Input meaning", text: $meaningInput)
        .focused($meaningFocus)
        .submitLabel(.done)
}

@FocusState 라는 프로퍼티래퍼를 사용해서 TextField의 포커스를 확인할 수 있다.

onSubmit 메서드로 확인이 눌렸으면 다음 포커스를 할 변수에 true를 넣어주어 전환가능.

submitLabel 메서드는 키보드에 엔터에 해당하는 키를 어떤 타입으로 사용할지 결정할 수 있다. (디자인도 변경됌)

CustomView

Flip Card Face

코드 길이가 길어서 주석으로 설명.

struct CardFace: View {
    let text: String // 카드에 들어갈 텍스트
    let isFlipped: Bool // 카드 뒤집기 애니메이션용 플래그
    let isFront: Bool // 앞인지 뒤인지 확인용 플래그

    var body: some View {
        Rectangle()
            .foregroundColor(isFront ? .green : .blue)
            .overlay(
                Text(text)
                    .frame(maxWidth: 300, maxHeight: 200) // 카드와 동일한 고정 프레임 지정 (최대치로)
                    .font(.system(size: 30, weight: .bold))
                    .foregroundColor(.white)
                    .rotation3DEffect( // 뒤집는 애니메이션을 사용하기 위해 rotation3DEffect 사용
                        .degrees(isFront ? 0 : 180), // Flip 플래그를 기준으로 0도 : 180도 로 뒤집음
                        axis: (x: 0, y: 1, z: 0) // 회전 축 설정
                    )
                    .lineLimit(nil) // 라인 수 제한 없음 (frame을 설정해서 프레임을 벗어나면...으로 표시됨
                    .multilineTextAlignment(.leading)
                    .padding(4)
            )
            .cornerRadius(10)
            .frame(width: 300, height: 200)
            .shadow(color: .gray, radius: 10) // 그림자 입히기 (Rectangle에)
            .rotation3DEffect( // 마찬가지로 텍스트만 회전하면 안되니까 넣어줌.
                .degrees(isFlipped ? 180 : 0),
                axis: (x: 0, y: 1, z: 0)
            )
    }
}

struct ContentView: View {
...
ZStack { 
// 두 카드를 겹쳐놓아 양면 카드처럼 한것이라 생각하면 됌.
// 뒤집어져 안보이는 쪽은 투명처리됌. (안하면 뒤집어져서 좌우 반전된 그대로 보여짐)
    // 앞면 (단어)
    CardFace(text: list[index].title, isFlipped: isFlipped, isFront: true)
        .opacity(isFlipped ? 0 : 1)

    // 뒷면 (의미)
    CardFace(text: list[index].meaning, isFlipped: isFlipped, isFront: false)
        .opacity(isFlipped ? 1 : 0)
}
.onTapGesture {
    withAnimation {
        isFlipped.toggle() // 토글 시 애니메이션이 되어야한다고 보내야지 부드럽게 Flip 함.
    }
}
...
}

여기서 생소한 메서드는 rotation3DEffect 였다.

이 메서드는 3D 축을 기준으로 지정한 각도만큼 회전시켜준다.

Toast message

.overlay(ToastMessage(toastMessage: $toastMessage, showToast: $showToast)) // 보여질 뷰에 overlay 

struct ToastMessage: View {
    @Binding var toastMessage: String
    @Binding var showToast: Bool
    var body: some View {
        VStack {
            Spacer()
            Text(toastMessage)
                .foregroundColor(.white)
                .padding()
                .background(Color.black.opacity(0.7))
                .cornerRadius(10) // 여기서 cornerRadius를 background 뒤에 적용
                .opacity(showToast ? 1 : 0)
                .offset(y: showToast ? 0 : 20)
        }
        .padding(.bottom, 40)
        .animation(.easeInOut(duration: 0.1), value: showToast)
    }
}

Others-

Dismiss View

@Environment(\.dismiss) var dismiss
// usage
dismiss()

@Environment 에 keyPath를 사용해 화면 종료 메서드를 접근해 사용? 할 수 있다. (접근해서 레퍼런스를 들고오는지는 불분명)

View Initializer

init(list: Binding<WordSet>, isCanEdit: Bool) {
    //Property Wrapper가 달려있는 변수의경우 _를 붙여서 접근할 수 있다.
    //State를 기본값을 받아 초기화 하는경우 State(initialValue:) 함수를 사용해 초기화할 수 있다.
    _list = list
    _title = State(initialValue: list.wrappedValue.title)
    self.isCanEdit = isCanEdit
}

초기화 구문을 커스텀해 만들 수 있다 (당연함)

하지만 프로퍼티래퍼가 붙은 변수들은 그냥 주입할 수 없다!

접근을 위해 변수명 앞에 _ 을 붙여줘야하고, 데이터 주입시에도 프로퍼티래퍼에 맞는 초기화 메서드를 사용해 저장해야한다.

  • @State - State(initialValue:)
  • @StateObject - StateObject(wrappedValue:)

Time API

private var timer: Timer? // 타이머 객체
@Published var timeCount: Int = 0 // 타이머 객체가 1초 지날때마다 카운트해줄 변수
@Published var elapsedTime: String = "" // 그 값을 00:00 으로 변환해 뷰에서 출력할 변수
// 타이머 시작
timer = Timer.scheduledTimer( 
    withTimeInterval: 1.0, // 1초에 한번씩
    repeats: true) { [weak self] _ in // 반복
    self?.timeCount += 1 // 1초에 한번씩 timeCount가 1씩 올라가게된다!
}

// 진행시간 확인용 (Combine)
$timeCount // 1초에 한번씩 값이 바뀌면 변경사항을 publisher가 감지하고 아래 내용을 실행함.
    .map{self.formatSecondsToMinutesSeconds($0)} // 포맷에 맞춰 String으로 변환
    .assign(to: &$elapsedTime) // 변환된 값을 elapsedTime에 주입함.

// 타이머 종료
timer?.invalidate() // 타이머 종료 메서드
timer = nil // 타이머 객체 release

주석으로 설명되어있음.

Formatter (DateFormatter with time)

private func formatSecondsToMinutesSeconds(_ seconds: Int) -> String {
    let formatter = DateComponentsFormatter()
    formatter.allowedUnits = [.minute, .second] // 분, 초만 표시
    formatter.unitsStyle = .positional // "0:00" 형식
    formatter.zeroFormattingBehavior = .pad // 01:05 같은 형식 유지
    return formatter.string(from: TimeInterval(seconds)) ?? "0:00"
}

Time 객체를 사용할 때 Int 값을 00:00 포맷에 맞게 변경하기위한 함수

Bundle Resource Load

private func loadGalleryList() {
    for fileName in GalleryWordList.allCases { // enum: String으로
            // Bundle.main.url을 하면 내가 만들고 있는 앱의 번들에 접근할 수 있다.
            // forResource는 번들 내 리소스에서 접근할때 쓰는 파라미터
        guard let url = Bundle.main.url(forResource: fileName.rawValue, withExtension: "json"),
              let data = try? Data(contentsOf: url) else { continue }
        do {
            let list = try JSONDecoder().decode(WordSet.self, from: data)
            gallery.append(list)
        } catch {
            print("Error decoding JSON: \(error)")
            return
        }
    }
}

Bundle에 있는 리소스 파일 (여기서는 JSON 파일을 넣어놓고 불러왔음)을 불러올 수 있다.

FileManager와 다르게 이건 앱 Bundle (Package) 내에서 파일을 가져오는 방법이다.

728x90