İçeriğe geç

SwiftUI 4 ile macOS'ta Pencere Yonetimi

Mac uygulamamin pencere yonetimini SwiftUI 4'e yukseltirken ogrendiklerim. `\.openWindow`, `.windowResizability` ve daha fazlasini acikliyorum.

SwiftUI 4 ile macOS'ta Pencere Yonetimi

SwiftUI her yil onemli olcude iyilesiyor. Gecen yil (2022’de), sadece iyilestirilmis navigasyon API’leri almadik. Apple ayni zamanda macOS destegini de buyuk olcude iyilestirdi — bence SwiftUI 4’te Mac uygulama gelistirme icin aldigimiz SwiftUI API’leri 1.0 seviyesinde ve nihayet en yaygin gorevlerin bazilari icin AppKit‘e basvurmak zorunda kalmadan SwiftUI icinde her turlu seyi yapmaya olanak taniyor. SwiftUI 3 kullanarak RemafoX uzerinde calisirken Mac’te SwiftUI deneyimledim ve bazi kisimlarda gercekten bir kabusdu.

iOS gelistirmeden gelerek, AppKit‘in tum detaylarini ogrenmek zorunda kalmamami umuyordum. Ama bir pencereyi kapatmak gibi en basit seyleri yapmak icin her turlu hileli kod yazmak zorunda kaldim. Ya da bir penceredeki tam ekran dugmesini devre disi birakmak. Ama iyi haber var: Uygulama hedefimi macOS 13.0’a yukselterek, nihayet pencere yonetimini SwiftUI uzerinden yapabildim ve uygulamdaki tum hileleri ortadan kaldirdim.

Sonunda, uygulamam gercekten %100 SwiftUI tabanli hissettiriyor. Ve iste basarmak istedigim goreve gore gruplanmis ve basliklandirilmis tum yeni API’ler. Pencere yonetimi muhtemelen SwiftUI doneminde iOS ve macOS gelistirme arasindaki en buyuk fark oldugundan, bu makale iOS’tan macOS’a gecis yapan herkesin Mac’te pencere yonetiminin nasil yapildigini anlamasina da yardimci olabilir.

Pencere Acmak

Bir WindowGroup kullaniyorsan (SwiftUI 3’te mevcut olan tek pencere turu buydu), SwiftUI 4 ile iki secenegin var: Birincisi, daha once de desteklenen handlesExternalEvents metodunu kullanmak:

enum Window: String, Identifiable {
   case paywall
   // ...
   var id: String { self.rawValue }
}

@main
struct AppView: App {
   var body: some Scene {
      // ...
      WindowGroup("Plan Chooser") { ... }
         .handlesExternalEvents(matching: [Window.paywall.id])
      // ...
   }
}

Bu pencereyi acmak istediginde, herhangi bir harici URL acabildigin gibi ama ozel URL semasi ile bir URL acman gerekir. Ornegin:

@main
struct AppView: App {
   @Environment(\.openURL)
   var openURL

   var body: some Scene {
      // ...
      WindowGroup(...) { ... }
         .commands {
            CommandGroup(after: .windowArrangement) {
               Button("Show Plan Chooser") {
                  self.openURL(URL(string: "remafox://\(Window.paywall.id)")!)
               }
               .keyboardShortcut("1")
            }
         }
   }
}

Bu yontem yeni Window turunde calismaz, her ne kadar orada da tamamen mevcut olsa da. Dokumantasyon bu konuda net:

This modifier is only supported for WindowGroup Scene types.

Ama ikinci yontem hem WindowGroup hem de Window icin calisiyor: Yeni \.openWindow environment degeri! Oncelikle initializer’da bir id tanimliyoruz:

enum Window: String, Identifiable {
   case paywall
   // ...
   var id: String { self.rawValue }
}

@main
struct AppView: App {
   var body: some Scene {
      // ...
      WindowGroup("Plan Chooser", id: Window.paywall.id) { ... }
      // ...
   }
}

Sonra, gosterimi manuel olarak tetiklemek icin o id’yi openWindow’a geciriyoruz:

@main
struct AppView: App {
   @Environment(\.openWindow)
   var openWindow

   var body: some Scene {
      // ...
      WindowGroup(...) { ... }
         .commands {
            CommandGroup(after: .windowArrangement) {
               Button("Show Plan Chooser") {
                  self.openWindow(id: Window.paywall.id)
               }
               .keyboardShortcut("1")
            }
         }
      // ...
   }
}

Bu cok daha temiz! DocumentGroup icin \.openDocument da oldugunu not et.

Tekrarlayan Pencereleri Onlemek

Pencere icin id kullanmak, birden fazla olusturulmasini engellemez:

Prevent duplicate windows

En azindan WindowGroup icin degil, ama ayni id’ye sahip bir pencerenin birden fazla olusturulmamasini saglamak icin basitce Window kullanabilirsin! Ama bu her zaman bir secenek degil.

Window, WindowGroup‘a kiyasla cesitli sekillerde cok daha kisitli. Ornegin, yukarda yaptigim gibi uygulamanin ana menusunde dugmeler ayarlamak icin uzerine .commands cagiramazsin. Ayrica Window yalnizca macOS’a ozgu bir API, bu yuzden kodu iPadOS’ta yeniden kullanamazsin. Benim RemafoX icin kullanamamammin nedeni daha once bahsettigim kisitlama: Window, handlesExternalEvents‘i desteklemiyor. Ama bu API’ye ihtiyacim var cunku RemafoX, hem bir extension hem de CLI araci araciligiyla Xcode ile derinlemesine entegre ve bunlar \.openWindow kullanamiyor cunku ayni hedefin parcasi degiller. Ama yine de “harici” URL’ler acabiliyorlar!

WindowGroup’un tekrarini onlemek icin senin neden ne olursa olsun, sunu yap:

WindowGroup("Plan Chooser", id: Window.paywall.id, for: String.self) { _ in
   // ...
} defaultValue: {
   Window.paywall.id
}

Ek for type ve defaultValue argumanlari alan WindowGroup initializer’inin asiri yuklu bir versiyonunu kullaniyor. Istedigin herhangi bir veri turune (mesela Profile turu) ozel bir pencere acmak icin kullanilabilir, ama ben burada pencereyi benzersiz kilmak icin basitce String turundeki id’yi yeniden kullaniyorum.

Benim durumumda bunu bircok yerde yapmam gerekti, bu yuzden su extension’i olusturdum:

extension WindowGroup {
   init<W: Identifiable, C: View>(_ titleKey: LocalizedStringKey, uniqueWindow: W, @ViewBuilder content: @escaping () -> C)
   where W.ID == String, Content == PresentedWindowContent<String, C> {
      self.init(titleKey, id: uniqueWindow.id, for: String.self) { _ in
         content()
      } defaultValue: {
         uniqueWindow.id
      }
   }
}

Bununla, iki Window.paywall.id cagrisi olan yukaridaki kullanim sadece suna donustu:

WindowGroup("Plan Chooser", uniqueWindow: Window.paywall) {
   // ...
}

Pencere acmak icin ek olarak openWindow’a value parametresini geciriyoruz:

Button("Show Plan Chooser") {
   self.openWindow(id: Window.paywall.id, value: Window.paywall.id)
}

Yine, Window.paywall.id’ye yapilan birden fazla cagri rahatsiz etti, bu yuzden bir yardimci olusturdum:

extension OpenWindowAction {
   func callAsFunction<W: Identifiable>(_ window: W) where W.ID == String {
      self.callAsFunction(id: window.id, value: window.id)
   }
}

Simdi basitce cagirabilirim, .id son ekinden bile kurtularak:

self.openWindow(Window.paywall)

Pencere Kapatmak

Artik bir pencereyi (benzersiz olarak) acabildik, haydi kapatmayi da yapalim. Ve burada, daha onceki durum WindowGroup icin bazi gecici cozumlerimiz olan durumdan cok daha kotudu. SwiftUI’da dogrudan bir pencere kapatmanin hicbir yolu yoktu. AppKit kullanarak su hileyi uygulamak zorunda kalmistim:

struct WindowAccessor: NSViewRepresentable {
   @Binding
   var window: NSWindow?

   func makeNSView(context: Context) -> NSView {
      let view = NSView()
      DispatchQueue.main.async {
         self.window = view.window
      }
      return view
   }

   func updateNSView(_ nsView: NSView, context: Context) {}
}

@main
struct AppView: App {
   @State
   private var window: NSWindow?

   var body: some Scene {
      WindowGroup(...) {
         SomeView(...)
            .background(WindowAccessor(window: self.$window))
      }
   }
}

Pencereyi kapatmak istedigimde self.window.close() cagirirdim.

2021’de, sheet, popover veya fullScreenCover gibi sunulmus view’lari dogrudan iclerinden kapatmayi saglayan \.dismiss environment degeri eklendi. Bu davranisinn o zaman zaten mevcut olup olmadini veya 2022’de mi eklendignden emin degilim, ama bugun dokumantasyon ek olarak dismiss action’inin su amacla kullanilabilecegini belirtiyor:

Close a window that you create with WindowGroup or Window.

Bu kesinlikle yalnizca sorunlu pencerede o anda sunulmus bir modal view yoksa calisiyor. Ama bu durumda harika calisiyor, basitce sunu yazabiliriz:

@main
struct AppView: App {
   @Environment(\.dismiss)
   var dismiss

   var body: some Scene {
      WindowGroup(...) {
         // ...
         Button("Close") {
            self.dismiss()  // <= this closes the window if no modal
         }
      }
   }
}

Tam Ekran Dugmesini Devre Disi Birakmak

Disabling full screen

Daha once, sabit boyutlu view’lar icin — karsilama pencerem veya hakkinda pencerem gibi — tam ekran dugmesini devre disi birakmak gibi daha fazla yapilandirma yapmak icin bana NSWindow‘a erisim saglayan ayni hileden yararlaniyordum. Ama artik tam ekran dugmesini dolayli olarak devre disi birakmamizi saglayan yeni windowResizability modifier’imiz var. Varsayilan olarak, Settings (ki bu WindowGroup gibi bir Scene turu) haricindeki tum pencereler icin contentMinSize olarak ayarli. Ama artik sunu yapabiliriz:

@main
struct AppView: App {
   var body: some Scene {
      WindowGroup(...) {
         SomeView(...)
            .frame(maxWidth: 400, maxHeight: 400)
         }
         .windowResizability(.contentSize)
      }
   }
}

windowResizability‘yi .contentSize olarak ayarlayarak, SwiftUI’a frame modifier’inda belirttigimiz boyutlari daha siki takip etmesini soyluyoruz. Mantiksal bir sonuc olarak, view tarafindan belirtilen maksimum boyut kullanicinin mevcut ekran boyutundan kucukse, SwiftUI tam ekran dugmesini bizim icin otomatik olarak devre disi birakacak! Cok bariz veya dogrudan bir API degil ama mantikli. Eger 11 inc MacBook Air’in (2015’ten) yerel boyutu olan 1366 x 768’in altinda herhangi bir deger verirsek, cogu kullanici icin dugmeyi devre disi birakmus olmaliyiz.

TCA Ekstra Bilgiler

Benim gibi uygulamalarin icin The Composable Architecture (TCA) kullaniyorsan, bu yeni environment degerlerini reducer’larina en iyi nasil aktarabilecegini merak edebilirsin — pencere acma/kapatma gibi mantik orada olmali. TCA etrafindaki harika topluluk bunu zarif bir sekilde cozmeme yardimci oldu; ozellikle Thomas Grapperon, OnChange olarak yeniden adlandirdigim bir tur sagladi ve bunu projelerine buradan kopyalayip yapistirabilirsin. Sonra view’inda .onChange modifier’ini ekleyerek reducer’a aktarmak istedigin herhangi bir degeri gecir:

@Environment(\.openWindow)
var openWindow

var body: some View {
   WithViewStore(...) { viewStore in
      SomeView(...)
         .onChange(of: \.$openWindow, store: self.store) { window in
            self.openWindow(window)
         }
   }
}

\.$openWindow’un State’teki bir alana isaret ettigini not et, bu yuzden tanimamamiz gerekiyor:

struct SomeState {
   @OnChange
   var openWindow: Window?
}

Simdi reducer’larimizda basitce state degerini bir Window enum case’ine ayarlayabiliriz ve view degisikligi otomatik olarak @Environment degerine aktaracak. Bu ayni zamanda diger herhangi bir SwiftUI ozniteligiyle de calisiyor, ben @FocusState icin de kullandim!

Bu arada, TCA bir \.dismiss bagimliligıyla birlikte gelirken, reducer’da await self.dismiss() cagirmak (su anda) SwiftUI \.dismiss environment degeri gibi davranmayacak ve pencereyi kapatmayacak. Bunun yerine hicbir sey yapmayacak ve hatta bir uyari uretecek. Gecici cozum olarak, acik pencereleri gezerek, eslesmeyi bularak ve kapatarak AppKit API’lerini kullanan bir bagimlilk uyguladim. Gist’i buradan kopyalayip yapistirabilirsin, kullanimi soyle gorunuyor:

// add the dependency to your reducer
@Dependency(\.closeWindow)
var closeWindow

// close a window in a `run` effect
return .run { _ in await self.closeWindow(Window.paywall) }

Ve bugunku SwiftUI 4’te pencere yonetimi hakkinda paylasacaklarimin hepsi bu!

Bu yazıyı beğendin mi? Swift ipuçları ve indie geliştirici güncellemeleri için Bluesky ve Mastodon üzerinden takip et.