저번 포스팅에 이어서 이번에는 직접 위젯을 만들어보는 포스팅을 해보려 합니다. 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
로 되어있는 값을 찾아 저희는 아까 그 repositoryViewController
의 url
프로퍼티에 할당을 해줍니다
대략적인 앱 안에서의 구조는 설계가 끝난 것 같습니다(만 이따가 좀 더 할 것이 남아있습니다). 드디어 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입니다.
configurationDisplayName
과 description
은 우리가 위젯을 처음 선택할때 화면(위젯 갤러리라고 하네요)에서 표시되는 텍스트를 보여줍니다 밑의 스크린샷과 같이요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가 위치하게끔 VStack
과 Spacer()
를 활용해서 했고 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
를 인스턴스해서 가지고 있습니다. 실제 프로젝트에서는 이 구조에서 확장시켜 데이터를 불러올 수 있겠습니다.
Interactor
와 Network
는 MyphotoWidgetExtension
과 데면데면하니 꼭 추가하는것을 잊지 마세요!
// 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
와 달리 snapshot
과 getTimeline
은 비동기식입니다. 그러기 때문에 네트워크를 통해 이미지 데이터를 받아오려면 비동기 처리를 이어지게 잘 처리 해야 하는데 많은 방법이 있겠지만 저는 DispatchGroup
을 사용했습니다
이 예제에서는 이미지가 한장이지만 이미지가 여러장을 불러올 수 있는 경우를 가정해서 가지고 와봅니다 그리고 fetchQueue
가 일이 끝나면 completion
으로 타임라인을 넘겨주게끔 해서 응답을 받는 시간을 보장받을 수 있게끔 합니다.
그리고 시뮬레이터에서 위젯을 추가하고 보여주면 짠!
(사실 위에랑 똑같은 gif 👀)
Widget과 SwiftUI를 몰랐을때는 앱과 어떻게 데이터를 주고받는지, 네트워크가 어떻게 이뤄지는지, 언제 새로고침이 되는지 궁금했는데 간단하게 만들어보면서 궁금증이 해소되었습니다. 더 다양한 위젯을 만들어봐야겠습니다 :)
https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension