아반떼오우너의 개발블로그 ㅋㅋ

JJGram - SwiftUI로 전환해보기 [1] 본문

iOS

JJGram - SwiftUI로 전환해보기 [1]

Avante 2021. 12. 8. 17:37

요즘 구글링하다보면 SwiftUI를 쓰는사람이 조금씩 있는것같다.

아직까지는 UIKit이 압도적으로 많을거고 많은 상용화되있는 앱들도 다 UIKit으로 만들어졌을테지만

Obj-C가 Swift로 대다수 대체되었듯, SwiftUI로 UIKit을 대체하는날이 오긴  올것같다.

(Apple이 밀어붙이는데 안쓰고 어떻게 배기겠냐만은..) 

그래서 SwiftUI도 공부해볼겸사겸사 내 개인앱 JJGram을 부분적으로라도 SwiftUI로 대체해보려고 한다.

아마 전부다는 당연히 못바꾸겠지만 아주 사소한부분이라도 한번 적용해볼거다.

 

1. 메인화면을 SwiftUI 컴포넌트만으로 변경해보기

JJGram의 메인 화면

앱의 가장 첫 화면인 메인부분을 SwiftUI로 변경해보고자 했다.

메인부분은 하단버튼들이 모여있는 UIView와 UICollectionView로 구현되어있다.

그러기 위해선 우선 UICollectionView를 SwiftUI로 대체해야했다. UICollectionView는 UIKit에 종속된 View이기 때문이다.

그리고 결과적으로 나는 이게 좋지 못한 방법이라는걸 알게되었다.

 

우선 기본적으로 SwiftUI에선 CollectionView라는 컴포넌트를 지원하지 않는다.

그래서 많은 사람들이 CollectionView를 SwiftUI컴포넌트를 이용해서 최대한 비슷하게 만들어서 사용하는것으로 보인다.

나도 LazyHStack과 ScrollView을 사용하여 CollectionView 비슷하게 만들어서 테스트를 진행해보았다.

struct ScrollViewOffset<Content: View>: View {
  let content: () -> Content
  let onOffsetChange: (CGFloat) -> Void

  init(@ViewBuilder content: @escaping () -> Content,
       onOffsetChange: @escaping (CGFloat) -> Void) {
    self.content = content
    self.onOffsetChange = onOffsetChange
  }

  var body: some View {
    ScrollView(showsIndicators: false) {
      offsetReader
      content().padding(.top)
    }
    .coordinateSpace(name: "frameLayer")
    .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)
  }

  var offsetReader: some View {
    GeometryReader { proxy in
      Color.clear
        .preference(
          key: OffsetPreferenceKey.self,
          value: proxy.frame(in: .named("frameLayer")).minY
        )
    }
    .frame(height: 0)
  }
}

위의 view Struct를 이용해서 CollectionView 비슷한 동작을 구현하긴 하였으나..

기존 UIScrollView의 Delegate에 존재하는 scrollViewWillEndDragging와 같은 편의성(?) 함수들이 SwiftUI에선 어떻게 구현해야할지 감이 안왔다.

JJGram은 Paging을 위해 위 기능이 반드시 필요한데 말이다.

현재로선 정확히 어떻게 구현해야할지 못찾아서 SwiftUI 컴포넌트만으로 CollectionView를 완전히 대체하기는 쉽지않겠다 라는 결론만 내린채 일단락 시켰다.

 

2. UIKit과 SwiftUI를 같이 사용하기.

그래서 내가 내린 결론은 UICollectionView를 그대로 사용하고

SwiftUI에서 UICollectionView를 가져와서 사용하도록 하였다. 현 시점에서의 완전한 SwiftUI로의 전환은 처음부터 어려울것이라 생각했기때문에 어느정도의 합의를 스스로 보았다.

메인화면 큰 틀의 UI는 SwiftUI로 구현하고 그 내부의 UICollectionView를 UIViewRepresentable을 상속받아 사용하였다.

아래와같이 하니 내가 흔히 보던 UICollectionView를 그대로 SwiftUI사용이 가능해졌다.

// MainView
struct MainListView: View {
  @State var presented: Bool = false
  @State private var position: CGPoint = .zero
  
    var body: some View {
      GeometryReader { geometry in
        ZStack {
          let topOffset = JJGramConstants.filterMainCellHeight -
                          JJGramConstants.mainBottomViewHeight -
                          geometry.safeAreaInsets.bottom
          let topSafeAreaPadding = geometry.safeAreaInsets.top
          
          MainListCollectionView(presented: $presented).onAppear(perform: {
            self.presented = true
          }).frame(width: JJGramConstants.filterMainCellWidth,
                   height: topOffset).ignoresSafeArea(edges: .top)
          SeparatorGradationView().frame(width: JJGramConstants.filterMainCellWidth,
                                          height: 20).offset(x: position.x, y: topOffset / 2.0 - topSafeAreaPadding - 5.0)
          
          BottomMenuView().frame(width: JJGramConstants.filterMainCellWidth,
                                  height: JJGramConstants.mainBottomViewHeight).offset(x: 0, y: topOffset / 2.0 - topSafeAreaPadding + 30.0)
        }
      }
    }
}

// UICollectionView를 UIViewRepresentable를 이용하여 사용.
struct MainListCollectionView: UIViewRepresentable {
  @Binding var presented: Bool
  var collectionView: UICollectionView?
  
  func makeUIView(context: Context) -> UICollectionView {
    let layout = UICollectionViewFlowLayout()
    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    return collectionView
  }
  
  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }
  
  func updateUIView(_ uiView: UICollectionView, context: Context) {
    if presented == true {
      uiView.dataSource = context.coordinator
      uiView.delegate = context.coordinator
      uiView.register(MainFilterCell.self, forCellWithReuseIdentifier: MainFilterCell.defaultReuseIdentifier)
    }
  }
  
  class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UIScrollViewDelegate {
    private var parent: MainListCollectionView
    
    init(_ collectionView: MainListCollectionView) { self.parent = collectionView }
    func numberOfSections(in collectionView: UICollectionView) -> Int { ... }
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { ... }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { ... }
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { ... }
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { ... }
  }
}

그 결과 아래와같이 UIKit을 사용할때와 동일한 UI구성이 완료되었다.

 

3. SwiftUI를 사용하면서 느낀점

SwiftUI를 사용하면 할수록 공부 많이해야겠다는 생각이 들었다.

AutoLayout을 이용하는것에 적응이 되어있는 나는 일단 기본적인 위치를 잡는것부터 한참 헤맸고 지금도 헤매고있다.

예를들어 자식뷰를 '부모뷰로부터 상단 몇픽셀 띄우기' 혹은 '슈퍼뷰의 하단에 붙이기'등 Relative하게 위치시키는것이 상당히 어렵다.

View를 구성할때 상대적인 위치를 많이 사용하는데 SwiftUI를 사용하면 이부분이 불편했다.

그래서 위의 SwiftUI 전환 코드도 위치값들을 일일히 계산해줬는데 이러면 나중에 유지보수에도 불편할것같았다.

또한 중괄호{} 가 너무 많이 쓰일 여지가 있다.

물론 컴포넌트들을 모듈화하여 쓰면 되겠지만 View를 몇개만 구성하는데도 중괄호가 너무 많이 붙어 이게 어느 Scope에 있는 코드인지 정확히 분간도 어렵다.

 

요약하자면 AutoLayout에 적응되어있는 난 SwiftUI에서 가장 사용하게 어려웠던 점은

1. View들을 Relative하게 위치시키기 어렵다.

2. 중괄호가 많이 쓰일여지가 있어 코드 가독성이 상당히 떨어질수있다.

3. Relative하게 위치하는경우 '어느 뷰가 어느 뷰의 어디에 위치하는구나'를 직관적으로 알수있었으나

V,H,ZStack으로 묶이고 위치가 상수값으로 설정되다보니 뷰 위치에 대해 직관적인 느낌을 못받았다.

4. 상용화되는 앱의 경우 코드량이 방대하고 필요에 따라선 Native 라이브러리를 임포트해서 쓰는 경우도 많다.(.a, .so처럼 빌드결과물이 아닌 코드 통째로 임포트 되는경우)

이런 경우 Preview를 볼때마다 매번 Xcode 빌드가 되는데 실무에서 크게 쓰이는 기능일까? 라는 생각이 들었다.

Preview만 보는경우 기존 @IBDesignable사용과 다를게 없었다.

5. Native 라이브러리 사용시 반드시 x86 아키텍쳐 빌드를 넣어줘야한다. 안그러면 Preview에서 자체빌드시 빌드에러가 난다.

즉, IntelCore MacBook은 x86환경에서만 Preview 사용이 가능하다. (M1, M1X를 사용하면 괜찮으려나?)

 

너무 부정적인 내용만 적은것같은데 아직까지는 SwiftUI를 잘 몰라서 그런거라고 생각한다.

SwiftUI를 좀더 공부해보면 위의 어려운점이 해소가 될거라 생각하고

좀더 기본부터 공부를 해봐야할것같다.