[swift]Widget 둘러보고 간단히 만들어보기(2)

okstring·2021년 12월 28일
0

Widget

목록 보기
2/2
post-thumbnail

만들어보기

저번 포스팅에 이어서 이번에는 직접 위젯을 만들어보는 포스팅을 해보려 합니다. H.I.G에서도 언급했듯이 앱을 실행만 하는 위젯은 지양해야 하기 때문에 프로필사진으로 꾸며진 위젯을 누르게 되면 WKWebView로 표시해주는 간단한 토이 프로젝트를 진행하려 합니다.

File - New - Target을 눌러 Widget Extension을 불러옵니다

전 StaticConfiguration으로 진행했습니다 적절한 이름을 지어주고 Finish!

폴더 구조는 편의상 이렇게 나눴습니다.

일단 이렇게 만들어두고! 실제 앱에서 URL과 Profile URL을 가지고 있는 Interactor역할, 네트워크 역할, 위젯을 클릭하면 푸시해서 보여줄 WKWebView를 간단히 만들어 봅시다 일단 완성된 모습을 볼까요?

요런걸 만들어보려 합니당. 저는 참고로 Storyboard없이 진행했습니다.

//SceneDelegate

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

    guard let windowScene = (scene as? UIWindowScene) else { return }

    let window = UIWindow(windowScene: windowScene)
    let vc = ViewController()
    let nc = UINavigationController(rootViewController: vc)
    vc.view.backgroundColor = .white
    window.rootViewController = nc
    self.window = window
    window.makeKeyAndVisible()

}

화면을 표시해주기 위해 기본적인 세팅을 해주구요

//ViewController

class ViewController: UIViewController {
    
    lazy var mainLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 24, weight: .bold)
        label.text = "여기는 메인입니다 :)"
        label.textColor = .black
        label.sizeToFit()
        label.center = self.view.center
        return label
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(mainLabel)
    }
}

일단 Main이 될 ViewController를 간단히 구성해봅니다. 라벨만 표시해줬구요

//RepositoryViewController

class RepositoryViewController: UIViewController {
    var url = ""
    
    let webView = WKWebView()
    
    override func loadView() {
        super.loadView()
        
        webView.frame = self.view.frame
        self.view = webView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let url = URL(string: url)
        let request = URLRequest(url: url!)
        
        self.webView.allowsBackForwardNavigationGestures = true
        
        webView.load(request)
    }
}

푸쉬해서 GitHub 프로필을 보여주는 RepositoryViewController는 이렇게 구성했습니다. 예제이니 구조를 많이 생략하고 강제언래핑 평소에는 못쓰니 이럴때 아낌없이 팍팍 써봤습니당

그러면! url을 읽어서 화면에 보여주는데 url은 어디서 받아오냐면 Scheme을 쿼리와 함께 이용해 앱을 호출하고, SceneDelegate를 통해 분기처리할 예정입니다.

그렇다면 Scheme을 일단 등록하러 가봅시다 Targer에 가서 info - URL Types에 가서 등록을 해주시면 되는데요

저어기 URL Schemes이 중요한데요 저기에다가 원하는 URL Schemes를 넣어줍니다 저는 lab으로 했어요

그리고 SceneDelegate에서 다시 돌아가 코드를 추가해줍니다

// SceneDelegate

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    if let url = URLContexts.first?.url {
        if url.absoluteString.starts(with: "lab://repo") {

            guard let urlComponents = URLComponents(string: url.absoluteString),
                  let repositoryURL = urlComponents.queryItems?.first(where: { $0.name == "url" })?.value else {
                      return
                  }

            let repositoryViewController = RepositoryViewController()
            repositoryViewController.url = repositoryURL

            let nv = self.window?.rootViewController as? UINavigationController
            nv?.pushViewController(repositoryViewController, animated: true)
        }
    }
}

lab://repo 로 시작하는 url 중 쿼리 아이템이 url로 되어있는 값을 찾아 저희는 아까 그 repositoryViewControllerurl 프로퍼티에 할당을 해줍니다

대략적인 앱 안에서의 구조는 설계가 끝난 것 같습니다(만 이따가 좀 더 할 것이 남아있습니다). 드디어 Widget을 꾸미러 가볼까요?

//MyPhotoWidget

@main
struct MyPhotoWidget: Widget {
    let kind: String = "kr.hahahoho.Lab.MyPhotoWidget" // 1

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind,
                            provider: Provider()) { entry in
            MyPhotoWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Github Profile") // 2
        .description("User Repository로 이동합니다.") 
        .supportedFamilies([.systemSmall]) // 4

    }
}

main이 되는 MyPhotoWidget입니다.

  1. kind는 위젯을 식별하는 문자열로 유니크하게 작성을 해줍니다.
  2. configurationDisplayNamedescription 은 우리가 위젯을 처음 선택할때 화면(위젯 갤러리라고 하네요)에서 표시되는 텍스트를 보여줍니다 밑의 스크린샷과 같이요
  3. supportedFamilies 은 지원하는 위젯 크기를 반환합니다. 저는 .systemSmall 로만 진행했습니다

//MyPhotoEntry

struct MyPhotoEntry: TimelineEntry {
    let date: Date
    let defaultImageName: String = "okstring"
    let imageData: Data
    let profileURL: String
}

TimelineEntry는 이렇게 구성되어 있습니다. 저기서 date는 당연히 가지고 있어야 하는 프로퍼티입니다. defaultImageName은 만약 네트워크로 이미지를 못받아오거나 placeholder, Snapshot 에서 보일 용도로 Assets에 기본이 될 이미지를 넣어두고 저 이름으로 불러오게 할 요령입니다

// MyPhotoWidgetEntryView

struct MyPhotoWidgetEntryView : View {
    var entry: Provider.Entry
    
    var body: some View {
        VStack {
            Spacer()
            HStack(alignment: .firstTextBaseline) {
                VStack (alignment: .leading){
                    Text("okstring")
                        .font(.headline)
                        .fontWeight(.bold)
                        .allowsTightening(true)
                }
                Spacer()
                Text(DateFormatter.shortTimeFormatter.string(from: entry.date))
                    .font(.body)
                    .fontWeight(.regular)
            }
            
        }
        .padding(.all, 10)
        .background {
            entry.imageData.isEmpty
            ? Image("okstring")
            : Image(uiImage: UIImage(data: entry.imageData) ?? UIImage())
        }.widgetURL(URL(string: "lab://repo?url=\(entry.profileURL)"))
    }
}

MyPhotoWidgetEntryView입니다. 하단에 Text가 위치하게끔 VStackSpacer() 를 활용해서 했고 ZStack 을 통해서 이미지를 뒤에 표시할 수도 있지만 저는 .background 를 사용하고 .widgetURL로 entry에서 넘어오는 URL과 조합시켜 앱과 연결시키게 했습니다 또한 이미지 데이터가 없으면 기본 이미지가 보이도록 넣어뒀습니다

// MyPhotoProvider

func placeholder(in context: Context) -> MyPhotoEntry {
    MyPhotoEntry(date: Date(), imageData: Data(), profileURL: "")
}

func getSnapshot(in context: Context, completion: @escaping (MyPhotoEntry) -> ()) {
    let entry = MyPhotoEntry(date: Date(), imageData: Data(), profileURL: "")
    completion(entry)
}

MyPhotoProvider에서 일단 `placeholder(in:)getSnapshot(in:completion:을 정의해줍니다 placeholder(in:) 메소드가 필수기 때문에 일단 정의를 해뒀지만 이 포스팅에서는 다루고 있지 않습니다.

챔고로 placeholder(in:)는 비동기가 아닌 동기식이니 그 역할에 맞춰 잘 활용하시면 되겠습니당

// MyPhotoProvider (MyPhoto)

let interactor = Interactor()

...
//Interactor (Lab)

class Interactor {
    let network = Network()
    
    let imagesURL = ["https://avatars.githubusercontent.com/u/62657991?v=4"]
    let profileURL = ["https://github.com/okstring"]
    
    func fetchAllImageData(index: Int, completion: @escaping ((Result<Data, Error>) -> ())) {
        network.getImage(url: imagesURL[index]) { result in
            completion(result)
        }
    }
}
//Network (Lab)
class Network {
    
    func getImage(url: String, completion: @escaping (Result<Data, Error>) -> ()) {
        guard let url = URL(string: url) else {
            completion(.failure(NSError()))
            return
        }
        
        let request = URLRequest(url: url)
        
        URLSession.shared.dataTask(with: request) { data, _, error in
            guard let data = data else {
                completion(.failure(NSError()))
                return
            }
            
            DispatchQueue.main.async {
                completion(.success(data))
            }
        }.resume()
    }
}

MyPhotoProvider에서 앱의 데이터를 가지고 있는 Interactor 인스턴스를 만들어옵니다 이 interactor 안에는 예제를 위한 profileURL, imagesURL을 가지고 있고 네트워크 처리를 위해 Network를 인스턴스해서 가지고 있습니다. 실제 프로젝트에서는 이 구조에서 확장시켜 데이터를 불러올 수 있겠습니다.

InteractorNetworkMyphotoWidgetExtension 과 데면데면하니 꼭 추가하는것을 잊지 마세요!

// MyPhotoProvider

func getTimeline(in context: Context, completion: @escaping (Timeline<MyPhotoEntry>) -> ()) {
    var entries: [MyPhotoEntry] = []

    let currentDate = Date()

    let group = DispatchGroup()

    let fetchQueue = DispatchQueue(label: "kr.maylily.Lab.MyPhotoWidget.fetchQueue", attributes: .concurrent)

    let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!


    fetchQueue.async(group: group) {
        for index in 0..<interactor.imagesURL.count {
            group.enter()

            interactor.fetchAllImageData(index: index) { result in
                switch result {
                case .success(let data):
                    let entry = MyPhotoEntry(date: entryDate, imageData: data, profileURL: interactor.profileURL[index])
                    entries.append(entry)
                    group.leave()
                case .failure(let error):
                    print(error)
                    group.leave()
                }
            }
        }
    }

    let queueForGroup = DispatchQueue(label: "kr.maylily.Lab.MyPhotoWidget.queueForGroup")

    group.notify(queue: queueForGroup) {
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

    group.resume()

}

getTimeline(for:in:completion:) 입니다. 위에서 언급했듯이 placeholder 와 달리 snapshotgetTimeline 은 비동기식입니다. 그러기 때문에 네트워크를 통해 이미지 데이터를 받아오려면 비동기 처리를 이어지게 잘 처리 해야 하는데 많은 방법이 있겠지만 저는 DispatchGroup을 사용했습니다

이 예제에서는 이미지가 한장이지만 이미지가 여러장을 불러올 수 있는 경우를 가정해서 가지고 와봅니다 그리고 fetchQueue 가 일이 끝나면 completion 으로 타임라인을 넘겨주게끔 해서 응답을 받는 시간을 보장받을 수 있게끔 합니다.

그리고 시뮬레이터에서 위젯을 추가하고 보여주면 짠!

(사실 위에랑 똑같은 gif 👀)

마치며

Widget과 SwiftUI를 몰랐을때는 앱과 어떻게 데이터를 주고받는지, 네트워크가 어떻게 이뤄지는지, 언제 새로고침이 되는지 궁금했는데 간단하게 만들어보면서 궁금증이 해소되었습니다. 더 다양한 위젯을 만들어봐야겠습니다 :)


reference

https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension

https://eunjin3786.tistory.com/212

https://zeddios.tistory.com/1088?category=796110

profile
step by step

0개의 댓글