İçeriğe geç

SwiftUI'da Çoklu Seçici

Prototipleme amacıyla SwiftUI'da eksik olan bir bileşeni ekliyoruz.

SwiftUI'da Çoklu Seçici

SwiftUI kullanarak ilk ciddi uygulamımı geliştirirken, özellikle önceden sunulmuş view’lar kullanım senaryonu desteklediğinde, SwiftUI ile UI geliştirmenin ne kadar hızlı hale geldiği karşısında sürekli etkileniyordum. Tabii ki her türlü özel UI için hala kendi view’larımızı yazmamız gerekecek — mevcut olanları birleştirip modifier’larla düzenleyerek — ama en azından geliştiricilerin veri sunmak ve kullanıcılardan girdi almak için ihtiyaç duyabileceği en yaygın view’ları SwiftUI’ın desteklemesini beklerdim.

Eğer bu böyle olsaydı, SwiftUI prototipleme için bile kullanılabilirdi. Bir uygulama fikrinin “çalışan ama güzel olmayan” bir versiyonu hızlıca oluşturulup kullanıcılara gösterilebilir ve fikrin başarı şansı olup olmadığı doğrulanabilirdi. Ayrıca, bu şekilde hangi kısımların gerçekten çok daha anlaşılır bir UI’a ihtiyacı olduğu (henüz iyi anlaşılamayan kısımlar) ve hangilerinin bazı görsel düzenlemelerle varsayılan bileşenlerle kalabileceği konusunda da hızlıca geri bildirim toplanabilirdi.

Başka bir deyişle: SwiftUI bence MVP odaklı ürün geliştirmeyi çok daha fazla geliştirici için ilgi çekici hale getirme potansiyeline sahip. Bu kesinlikle iyi bir şey çünkü aksi takdirde bir şekilde başarısız olacak şeylere harcanacak tonla zamanı kurtarıyor. Bu da bence her türlü yeni ürüne yaklaşmanın harika bir yolu olan Yalın Girişim (Lean Startup) metodolojisi ile örtüşüyor.

SwiftUI’ın Mevcut Durumu

Bunun mümkün olması için, SwiftUI’ın kullanıcı kaydı veya diğer veri türleri için formlarda gerekebilecek tüm yaygın girdi türlerini zaten kapsamış olmasını beklerdim. Sonuçta birçok uygulama türü, aslında girdi verisi kabul eden, bir şekilde dönüştüren ve veriyi özel bir biçimde veya zamanda geri sunan bir formdan ibarettir. Maalesef SwiftUI henüz tam olarak orada değil.

Apple’ın SwiftUI ile izlediği yaklaşım, SwiftUI’da en çok eksik olan bileşenleri değerlendirip her yıl bazılarını eklemek gibi görünüyor. Örneğin WWDC 2020’de ProgressView, Gauge, Text içinde Image desteği eklediler ve mevcut view’ların hem performans hem de esneklik açısından birçok detayını iyileştirdiler. WWDC 2021’de ise AsyncImage veya .refreshable ve .task view modifier’ları gibi birçok async/await ilişkili API ve diğer iyileştirmeler & eklemeler eklediler.

Bu yaklaşımın iyi tarafı, framework’e bir şey eklendiğinde, onun uzun süre aynı şekilde var olacağını ve çalışacağını bekleyebilirsin — yani her sürümde büyük kod değişiklikleri gerekmez (Swift 4 öncesinde dilin kendisinde olduğu gibi). Kötü tarafı ise birçok bileşenin hala eksik olması. Ve işte tam burada topluluk devreye girip, Apple tarafından ileride sunulacak resmi bileşenlerle kolayca değiştirilebilecek geçici çözümler sunabilir.

Çoklu Seçim View Bileşenini Uygulamak

Bu yazıda, böyle bir bileşene odaklanmak ve ilk çözümümü sunmak istiyorum: Verilen bir seçenek kümesinden birden fazla seçenek seçmek için bir çoklu seçici. Şu an itibariyle Apple bir Picker sağlıyor, ama birden fazla girdi seçimini desteklemiyor ve tek bir seçim yapıldığında otomatik olarak liste ekranından çıkıyor. Haydi hemen bunu düzeltelim!

Ne tür bir veri yapısı çoklu seçici gerektirebilir? Şu örneğe bakalım:

struct Goal: Hashable, Identifiable {
    var name: String
    var id: String { name }
}

struct Task {
    var name: String
    var servingGoals: Set<Goal>
}

Yani temelde uygulamamızda bir hedefler koleksiyonu ve bir görevler koleksiyonu var. Ve her görevin hangi hedeflere hizmet ettiğini tanımlayan ilişkiyi modellemek istiyoruz. Bir Task oluştururken veya düzenlerken, görevin hangi hedeflere hizmet ettiğini seçmek istiyoruz. İşte TaskEditView için SwiftUI kodu:

import SwiftUI

struct TaskEditView: View {
    @State
    var task = Task(name: "", servingGoals: [])

    var body: some View {
        Form {
            Section(header: Text("Name")) {
                TextField("e.g. Find a good Japanese textbook", text: $task.name)
            }

            Section(header: Text("Relationships")) {
                Text("TODO: add multi selector here")
            }
        }.navigationTitle("Edit Task")
    }
}

struct TaskEditView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            TaskEditView()
        }
    }
}

Yukarıdaki kod şu önizlemeyi oluşturuyor:

Implementing a multi 3

Hizmet edecek tek bir hedefimiz olsaydı işlerin nasıl çalışacağını göstermek için, TODO Text girdisini şöyle bir Picker ile değiştirebilirdik:

// mock data:
let allGoals: [Goal] = [
    Goal(name: "Learn Japanese"),
    Goal(name: "Learn SwiftUI"),
    Goal(name: "Learn Serverless with Swift")
]

Picker("Serving Goal", selection: $task.servingGoal) {
    ForEach(allGoals) {
        Text($0.name).tag($0 as Goal)
    }
}

TaskEditView şimdi şöyle görünüyor:

Implementing a multi 6

Picker’a tıkladığınızda ise detay görünümü şöyle:

Implementing a multi 5

Oldukça basit. Goal‘un bunun çalışması için Identifiable olması gerektiğini not et, bu yüzden en başta var id: String { name } eklemiştim. Çoklu seçicimiz için aslında UI’ın hemen hemen aynı görünmesini istiyoruz, ama bir yerine birden fazla girdi seçebilmek istiyoruz.

Önce TaskEditView’daki girdiyi yeniden oluşturmamız gerekiyor; Picker’ın yerine geçecek tür adı olarak MultiSelector’ı seçtim. İşte implementasyonu:

import SwiftUI

struct MultiSelector<LabelView: View, Selectable: Identifiable & Hashable>: View {
    let label: LabelView
    let options: [Selectable]
    let optionToString: (Selectable) -> String
    var selected: Binding<Set<Selectable>>

    private var formattedSelectedListString: String {
        ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) })
    }

    var body: some View {
        NavigationLink(destination: multiSelectionView()) {
            HStack {
                label
                Spacer()
                Text(formattedSelectedListString)
                    .foregroundColor(.gray)
                    .multilineTextAlignment(.trailing)
            }
        }
    }

    private func multiSelectionView() -> some View {
        Text("TODO: add multi selection detail view here")
    }
}

Her girdiyi bir String ile temsil etmeye karar verdiğimi not et, bu yüzden seçenek türünün String temsilini sağlayacak optionToString closure’ı gerekiyor.

ListFormatter.localizedString çağrısı, seçili seçenekler listesini doğru yerelleştirme formatında birleştirmemizi sağlıyor (örneğin ["A", "B", "C"] İngilizce için “A, B and C” olur).

View için kullandığım önizleme kodu şu:

struct MultiSelector_Previews: PreviewProvider {
    struct IdentifiableString: Identifiable, Hashable {
        let string: String
        var id: String { string }
    }

    @State
    static var selected: Set<IdentifiableString> = Set(["A", "C"].map { IdentifiableString(string: $0) })

    static var previews: some View {
        NavigationView {
            Form {
                MultiSelector<Text, IdentifiableString>(
                    label: Text("Multiselect"),
                    options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
                    optionToString: { $0.string },
                    selected: $selected
                )
            }.navigationTitle("Title")
        }
    }
}

Önizlemeyi projeme özgü Goal yerine dahili bir tür kullanarak bağımsız yaptığımı not et. Önizleme şöyle görünüyor:

Implementing a multi

Bunu TaskEditView’ımıza yerleştirip o bağlamda nasıl göründüğüne bakalım — TODO Text çağrısını şununla değiştiriyoruz:

MultiSelector(
    label: Text("Serving Goals"),
    options: allGoals,
    optionToString: { $0.name },
    selected: $task.servingGoals
)

Önizleme artık şöyle değişiyor, tam beklediğimiz gibi:

Implementing a multi 4

Ama üzerine tıkladığımızda, henüz doğru olmayan şunu görüyoruz:

Implementing a multi 2

O zaman detay view’ını uygulayalım. Detay view’ı için MultiSelectionView tür adını seçtim ve kodu şu:

import SwiftUI

struct MultiSelectionView<Selectable: Identifiable & Hashable>: View {
    let options: [Selectable]
    let optionToString: (Selectable) -> String

    @Binding
    var selected: Set<Selectable>

    var body: some View {
        List {
            ForEach(options) { selectable in
                Button(action: { toggleSelection(selectable: selectable) }) {
                    HStack {
                        Text(optionToString(selectable)).foregroundColor(.black)

                        Spacer()

                        if selected.contains { $0.id == selectable.id } {
                            Image(systemName: "checkmark").foregroundColor(.accentColor)
                        }
                    }
                }.tag(selectable.id)
            }
        }.listStyle(GroupedListStyle())
    }

    private func toggleSelection(selectable: Selectable) {
        if let existingIndex = selected.firstIndex(where: { $0.id == selectable.id }) {
            selected.remove(at: existingIndex)
        } else {
            selected.insert(selectable)
        }
    }
}

label hariç, temel olarak aynı property’lere sahip. Ama bu sefer gerçekten kullanılıyorlar, örneğin selected koleksiyonunda contains çağırarak onay işaretinin gösterilip gösterilmeyeceğini kontrol ederken.

Girdilerden birine tıklandığında, toggleSelection o girdiyi selected property’sinden kaldırmak veya eklemek için kullanılıyor. Onay işareti için, Picker’ın onay işareti ikonuyla aynı görünen SF Symbol “checkmark” kullanıyorum.

Detay view’ı için kurduğum önizleme kodu şu, MultiSelector önizlemesinin neredeyse bir kopyası:

struct MultiSelectionView_Previews: PreviewProvider {
    struct IdentifiableString: Identifiable, Hashable {
        let string: String
        var id: String { string }
    }

    @State
    static var selected: Set<IdentifiableString> = Set(["A", "C"].map { IdentifiableString(string: $0) })

    static var previews: some View {
        NavigationView {
            MultiSelectionView(
                options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
                optionToString: { $0.string },
                selected: $selected
            )
        }
    }
}

Xcode önizlemesinde şöyle görünüyor:

Implementing a multi 7

Son olarak, MultiSelectionView’ımızı MultiSelector’ımıza entegre edelim — TODO Text girdisini şununla değiştiriyoruz:

MultiSelectionView(
    options: options,
    optionToString: optionToString,
    selected: selected
)

Temel olarak, verileri detay view’ına aktarıyoruz. Ama simülatörden kaydettiğim bu animasyonlu GIF ile uygulamamızın şimdi nasıl göründüğüne bakalım:

Implementing a multi

Güzel, çalışıyor!

Demo projeyi GitHub’a yükledim. MultiSelector ve MultiSelectionView içeriklerini kopyalamak istersen, bu klasörde bulabilirsin.

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