SwiftUI로 앱을 개발하며 정리하는 개발 팁

Flamozzi·2023년 4월 12일
1

추후에 제대로 정리할 예정.. 우선은 아무렇게나 기록 부터..

단축키 팁

  • xcode상에서 파일 빠르게 찾아서 이동하기: command + shift + o
  • 코드 정렬: control + a (or 필요한 코드 드래그) → control + i
  • 프리뷰 띄우기: command + option + enter
  • 프리뷰 없애기: command + enter
  • 새로운 그룹 만들기: 상위 폴더 선택 → command + option + n

아키텍처 구조

  • 기존 프로젝트에서는 MVVM 구조를 채택하여 왔지만, 주객전도가 되는 상황을 방지하기 위하여 architecture components가 중심이 아닌, feature 그 자체가 중심이 되는 구조를 채택한다.
  • 근본적으로는 MVVM의 철학에서 벗어나지 않고, MVVM 패턴을 좀 더 효율적인 폴더 디바이딩하겠다는 방향이다.
  • 예를 들어, 📁 Core 안의 📁 Feed 안에는 Feed에 대한 모든 기능들이 들어가게 되는 방식이며, 📁 Feed 안에 ViewModels와 Views가 존재하는 형태를 띈다.
  • 📁 Components 안에는 공통으로 쓰일 컴포넌트들이 자리한다. 트위터의 Feed View 안의 각 셀에 들어갈 하나 하나의 Tweet과 같은 녀석들이 들어 갈 예정.
  • 이를 통해 기존의 폴더 채택 방식에서 아키텍처 컴포넌트인 MVVM의 각 요소를 메인으로 폴더링을 할 때, View와 ViewModel이 굉장히 많아지면 적절한 폴더를 찾아다니는 게 어려워지는 단점을 극복할 수 있다.

코딩 컨벤션

  • SwiftUI를 작성하다 보면 Vstack Hstack 등이 매우 복잡하게 연결될 때가 있다. 가독성을 위해서는 각 스택의 정확한 구성 요소를 주석으로 작성해준다.
  • Hstack 내에 버튼이 여러개 배치되어 있고, 각각의 행동이 비슷한 등의 반복된 코드가 많은 경우, fold를 적극 활용하여 가독성을 높이자.
    • 또한 이러한 경우에 버튼 색을 한 번에 변경하고 싶다면, 버튼 하나하나를 바꿔주는 게 아닌, Hstack 자체에 .foreground(.gray)와 같은 식으로 변경하면 좋음
  • View를 작성할 때 main body에 코드가 지저분하게 많이 들어가면 보기 좋지 않음. 코드가 길어질 경우 extension의 var로 해당 뷰를 빼서 main bady는 깔끔해지도록 할 것.
    • ProfileView에서 extension으로 headerView를 만들고 main body에서는 호출만 하는 식
  • 최대한 Apple 에서 기본 제공하는 요소들을 잘 활용할 것.
  • ViewModel에서 enum을 활용할 때 CaseIterable 프로토콜을 채택해주면, View에서 viewModel.allcase와 같은 식으로 모든 케이스에 대한 호출이 간편해진다.
  • 삼항 연산자가 오히려 가독성을 해치는 경우가 아니라면, 적극 사용하도록 하자.
    // 삼항 연산자 사용 예시
    
    @State private var selectedFilter: TweetFilterViewModel = .tweets
    
    ...
    
    HStack {
    		ForEach(TweetFilterViewModel.allCases, id: \.rawValue) { item in
    				VStack {
    						Text(item.title)
    		            .font(.subheadline)
    		            .fontWeight(selectedFilter == item ? .semibold : .regular)
    		            .foregroundColor(selectedFilter == item ? .black : .gray)
    				}
    		}
    }
  • View에서 init을 해줄 때 ‘_’ 를 사용함으로써 호출시 argument label을 생략할 수 있다.
    struct TextArea: View {
        @Binding var text: String
        let placeholder: String
        
        init(_ placeholder: String, text: Binding<String>) {
            self.placeholder = placeholder
            self._text = text
        }
        ...
    }
    
    이를 추후 필요한 코드 부분에서 다음과 같이 호출 가능함
    let myTextArea = TextArea("Enter text here", text: $myText)
  • 공통으로 쓰이는 View는 Component로 빼서 reusable하게 만들 것
  • 로직에서 조건 검사를 할 때는 guard let을 적극 사용 할 것
  • 공통된 modifier를 반복해서 써야할 경우에 다음과 같이 따로 modifer를 정의해서 사용할 것
    private struct ProfileImageModifier: ViewModifier {
        func body(content: Content) -> some View {
            content
                .foregroundColor(Color(.systemBlue))
                .scaledToFill()
                .frame(width: 180, height: 180)
                .clipShape(Circle())
        }
    }

ViewModel은 struct? enum? 어떤 것으로 정의하는가

MVVM 패턴에서 ViewModel을 enum 또는 struct로 정의하는 것은 두 가지 다른 접근 방식입니다. 두 가지 방식 모두 ViewModel의 역할을 수행할 수 있으며, 선택하는 방식은 개인의 선호나 프로젝트의 요구 사항에 따라 다릅니다.

Enum을 사용하는 경우, ViewModel의 상태를 명확하게 정의할 수 있습니다. 각 상태는 명시적으로 정의되어 있으며, 상태 전이를 처리하는 데 유용합니다. 또한 enum은 연관된 값을 포함할 수 있으므로, ViewModel에서 특정 동작을 수행하는 데 필요한 모든 데이터를 포함할 수 있습니다.

반면, struct를 사용하는 경우, ViewModel의 구성 요소를 더 자세히 제어할 수 있습니다. 구조체는 클래스와 달리 값 형식이므로 복사 및 전달이 쉽습니다. 또한 struct를 사용하면 코드를 조직화하고 관련 데이터 및 동작을 묶을 수 있습니다.

따라서 선택은 개인의 취향이나 프로젝트의 요구 사항에 따라 다를 수 있습니다. Enum을 사용하면 ViewModel의 상태 전이를 관리하기 쉽지만, struct를 사용하면 ViewModel을 더 세분화하고 데이터와 동작을 더 잘 제어할 수 있습니다.

  • 추가

ViewModel에서 enum을 사용하는 가장 큰 의의는 얼마나 수정이 쉽냐는 것에 있다.

예를 들어 viemodel에 있는 많은 option(case) 중 하나를 삭제한다고 했을 때, viewModel에서만 삭제 작업을 진행 해주어도 view에서는 알아서 해당 사항이 잘 반영되기 때문이다. 추가의 경우에도 마찬가지.

→ 즉, single source of truth를 잘 지킨다고 할 수 있다. (원 파일에서 모두 컨트롤 가능하기 때문)

TextEditor View에서 UITextView.appearance().backgroundColor = .clear를 하는 이유?

UITextView.appearance().backgroundColor = .clear 코드는 SwiftUI의 TextEditor view를 커스터마이징하기 위해 사용되는 코드입니다.

SwiftUI의 TextEditor view는 실제로 UITextView를 기반으로 하며, UITextView의 배경색은 흰색입니다. 하지만 TextArea view의 배경색은 투명해야 하기 때문에, TextEditor의 배경색을 투명하게 만들어줘야 합니다.

따라서 UITextView의 appearance를 사용하여 전역적으로 모든 UITextView의 배경색을 투명으로 설정하는 것입니다. 이는 TextEditor view에 적용되어 배경색이 투명해지게 됩니다.

즉, UITextView의 appearance를 사용하여 TextEditor view의 기본 설정을 변경하여 사용자 정의를 수행하는 것입니다.

TextEditor의 placeholder를 사용할 때는 실제 text보다 placeholder가 ZStack상 앞에 존재해야한다.

import SwiftUI

struct TextArea: View {
    @Binding var text: String
    let placeholder: String
    
    init(_ placeholder: String, text: Binding<String>) {
        self.placeholder = placeholder
        self._text = text
        UITextView.appearance().backgroundColor = .clear
    }
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            
            TextEditor(text: $text)
                .padding(4)
            
            if text.isEmpty {
                Text(placeholder)
                    .foregroundColor(Color(.placeholderText))
                    .padding(.horizontal, 8)
                    .padding(.vertical, 12)
            }
        }
        .font(.body)
    }
}

또한, SwiftUI의 TextEditor에는 placeholder가 없기 때문에 위와 같이 직접 ZStack으로 구현해주어야 한다.

clipShape를 효과적으로 쓰기

import SwiftUI

// rounded corner를 주는 Shape struct
// View에서 .clipshape 수정자를 통해 .clipShape(RoundedShape(corners: [.bottomRight]))와 같은 식으로 사용 가능.
struct RoundedShape: Shape {
    var coners: UIRectCorner
    
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: coners, cornerRadii: CGSize(width: 80, height: 80))
        
        return Path(path.cgPath)
    }
}

위와 같이 RoundedShape를 커스텀하여 따로 Componenets로 정의해두면 추후에 View에서 원하는 곳의 corner를 쉽게 줄 수 있음.

앱에서 전체적으로 함께 사용되어야 하는 AuthViewModel

import SwiftUI
import Firebase

@main
struct TwitterSwiftUICloneApp: App {
    
    @StateObject var viewModel = AuthViewModel()
    
    init() {
        FirebaseApp.configure()
    }
    
    var body: some Scene {
        WindowGroup {
            NavigationView {
                ContentView()
            }
            .environmentObject(viewModel)
        }
    }
}

앱을 관통하여 여러 곳에서 사용되어야 하는 Auth와 같은 ViewModel들은 어디선가 초기화가 되어야 사용 할 수 있음. 따라서 위의 코드와 같이 root App file에서 @StateObject로 viewModel을 만들어주고, .environmentObject로 ContenView에 launch 해줄 것.

ViewModel에서 instance의 동일성을 유지하기 위해 class를 사용 할 것

https://showcove.medium.com/swift-struct-vs-class-1-68cf9cbf87ca

무작정 struct는 좋고 class는 지양해야 하는 생각 하지 말 것. 효용성의 목적이 다를 뿐.

외부 인자 이름(External Parameter Names)

func login(withEmail email: String, password: String) {
        print("DEBUG: Login with email \(email)")
    }
    
    func register(withEmail email: String, password: String, fullname: String, username: String) {
        print("DEBUG: Register with email \(email)")
    }

때론 함수를 호출할 때 각각의 인자에 이름을 붙이는게 유용할 때가 있는데 함수로 던져진 각 인자의 목적을 가르키기 위함.

함수 사용자에게 함수를 호출 할 때 인자에 이름을 지어주길 원하면 각 인자에게 외부 인자 이름을 지역 인자 이름에 붙이도록 정의함.

지역 인자 이름 앞에 한칸 띄우고 외부 인자 이름을 작성함.

@Published

ViewModel에서 변수를 @Published로 정의해주면 해당 변수에 변화가 생겼을 때 View에 즉각 Notificate하여 변화를 적용시킬 수 있음

AuthViewModel에서 User와 관련된 변수는 Optina로 정의할 것

@Published var currentUser: User? // currentUser를 nil로 하는 이유는 앱을 실행하고 Data를 Fetch 하기 전까지 아주 짧은 시간이지만 반드시 nil이 되기 때문에 충돌을 방지하기 위함.

Current User와 같이 View에서 Data를 Fetch해야 하는 경우 다음과 같이 두 가지 방법으로 가능함 (2-ways: get data from one place to another)

// way 1 (with init func, dependency injection 사용)
private let user: User
    
    init(user: User) {
        self.user = user
    }
...

Text(user.fullname)
		.font(.title2).bold()

---

// way 2 (without init func, just set viewModel)
@EnvironmentObject var authViewModel: AuthViewModel
...

var body: some View {
        if let user = authViewModel.currentUser {
            VStack(alignment: .leading, spacing: 32) {
                VStack(alignment: .leading) {
                    KFImage(URL(string: user.profileImageUrl)) // Kingfisher 라이브러리를 사용하여 이미지 fetch
                        .resizable()
                        .scaledToFill()
                        .clipShape(Circle())
                        .frame(width: 48, height: 48)
                    
                    VStack(alignment: .leading, spacing: 4) {
                        Text(user.fullname)
                            .font(.headline)
                        
                        Text("@\(user.username)")
                            .font(.caption)
                            .foregroundColor(.gray)
                    }
                    
                    UserStatsView()
                        .padding(.vertical)
                }
...

상황에 맞게 적절한 방법 선택하여 사용할 것

SwiftUI NavigationBarTitle이 제대로 반영되지 않는 버그 픽스

import SwiftUI

struct MainTabView: View {
    @State private var selectedIndex = 0
    
    var body: some View {
        TabView(selection: $selectedIndex) {
            FeedView()
                .onTapGesture {
                    self.selectedIndex = 0
                }
                .tabItem {
                    Image(systemName: "house")
                }.tag(0)
            
            ExploreView()
                .onTapGesture {
                    self.selectedIndex = 1
                }
                .tabItem {
                    Image(systemName: "magnifyingglass")
                }.tag(1)
            
            NotificationView()
                .onTapGesture {
                    self.selectedIndex = 2
                }
                .tabItem {
                    Image(systemName: "bell")
                }.tag(2)
            
            MessagesView()
                .onTapGesture {
                    self.selectedIndex = 3
                }
                .tabItem {
                    Image(systemName: "envelope")
                }.tag(3)
        }
        .navigationTitle(titleForIndex(selectedIndex))
        .navigationBarTitleDisplayMode(.inline)
    }
    
    private func titleForIndex(_ index: Int) -> String {
        switch index {
        case 0: return "Home"
        case 1: return "Explore"
        case 2: return "Notifications"
        case 3: return "Messages"
        default: return ""
        }
    }
}

struct MainTabView_Previews: PreviewProvider {
    static var previews: some View {
        MainTabView()
    }
}
profile
개발도 하고, 글도 적고

0개의 댓글