Ders 03 / 15
Gezinme Denetleyicileri
Bir gezinme denetleyicisinde ekran itilip geri çıkarıldığında modelden kalkan bir seçimin listede artık olarak kaldığının ve itilen ekranın modelle uyuştuğunun ölçümü.
Yayın Güncelleme
Bu derste
- Gezinme denetleyicisinin ekranları bir yığıtta tuttuğunu ve itme ile geri çıkarmanın yığıtı nasıl değiştirdiğini anlatır
- Geri çıkarılan ekranın altındaki ekranın kendiliğinden güncellenmediğini ölçer
- Modelden kalkan bir değerin ekranda artık olarak kalmasını tanır
- İtilen ekranın ilk yazmasını bir sapma saymayan ölçüm sınırını ayırır
İçindekiler
Önceki derste ekran bir kez ekrana geldi, başka bir ekranın altında kaldı ve geri döndü. O başka ekran nereden geliyordu? Çoğu uygulamada ekranlar birbirinin üstüne gezinme denetleyicisi (navigation controller) ile konur. Gezinme denetleyicisi ekranları bir yığıtta tutar: yeni ekran yığıtın üstüne itilir (push), geri dönülünce üstteki ekran yığıttan geri çıkarılır (pop) ve altındaki ekran yeniden görünür.
Bu iş bir yönetim işidir; ekranların içeriğini denetleyici yönetmez. Altta kalan ekranın ne gösterdiği, o ekranın denetleyicisinin yazdığı şeydir. Bu derste bunun bir sonucunu ölçeriz: liste ekranında seçili bir satır var, kullanıcı ayrıntıya iner ve geri döner; seçim modelden kalkmıştır ama liste bundan haberdar mıdır.
İtme ve Geri Çıkarma
Aşağıdaki parça bir kişi listesini ve ayrıntı ekranını gösterir. Platform çerçevesini içe aldığı için çalıştırılmaz.
import UIKit
final class ContactListController: UIViewController {
let store: ContactStore
private let selectedLabel = UILabel()
private let countLabel = UILabel()
init(store: ContactStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
countLabel.text = store.values["count"] ?? ""
}
func select(_ name: String) {
store.update("selected", name)
selectedLabel.text = name
navigationController?.pushViewController(ContactDetailController(store: store), animated: true)
}
}
final class ContactDetailController: UIViewController {
let store: ContactStore
private let selectedLabel = UILabel()
private let phoneLabel = UILabel()
init(store: ContactStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
selectedLabel.text = store.values["selected"] ?? ""
phoneLabel.text = store.values["phone"] ?? ""
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil { store.remove("selected") }
}
}
select seçimi hem modele hem listenin etiketine yazar ve ayrıntıyı iter. Ayrıntı denetleyicisi yığıttan çıkarıldığında didMove(toParent:) ebeveyni nil olarak alır ve seçimi modelden kaldırır. Listenin kendi denetleyicisinde seçim etiketini temizleyen bir kod yoktur.
Yığıtı Yöneten Denetleyici
Gezinme denetleyicisi, çocuk denetleyicilerden oluşan bir yığıt tutar. En üstteki denetleyici ekranda görünür; pushViewController yığıtın üstüne bir denetleyici koyar, popViewController en üsttekini çıkarır. Yığıt, içindeki denetleyicileri güçlü tutar; bu yüzden yığıtta bekleyen bir denetleyici bellekte kalır ve görünümü yeniden yüklenmez.
import UIKit
final class ContactListController: UIViewController {
let store: ContactStore
init(store: ContactStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func showDetail(for name: String) {
let detail = ContactDetailController(store: store, name: name)
navigationController?.pushViewController(detail, animated: true)
}
}
navigationController özelliği, denetleyicinin içinde bulunduğu gezinme denetleyicisini verir; denetleyici bir yığıtta değilse nil döner ve itme çağrısı sessizce hiçbir şey yapmaz. Gezinme çubuğundaki geri düğmesi çerçevenin kendisi tarafından bağlanır: dokunuş en üstteki denetleyiciyi geri çıkarır ve popViewController çağrısını kodla yazmak gerekmez.
Ölçü
Ölçü, kurs boyunca aynı çekirdektir.
// Sync.swift — ekranda gosterilen her alani modelin o anki degeriyle karsilastirir
final class Screen {
let name: String
private(set) var shown: [String: String] = [:]
init(_ name: String) { self.name = name }
func set(_ key: String, _ value: String) { shown[key] = value }
func clear(_ key: String) { shown[key] = nil }
}
func verdict(model: String?, shown: String?) -> String {
switch (model, shown) {
case (nil, nil): return "olcum-disi"
case (nil, _?): return "artik"
case (_?, nil): return "eksik"
case let (wanted?, displayed?): return wanted == displayed ? "uyumlu" : "bayat"
}
}
func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
keys.map { key in
let result = verdict(model: model[key], shown: screen.shown[key])
print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") ekran=\(screen.shown[key] ?? "-") -> \(result)")
return result
}
}
func countVerdicts(_ verdicts: [String]) {
for name in ["uyumlu", "bayat", "eksik", "artik"] {
print("\(name): \(verdicts.filter { $0 == name }.count)")
}
}
Geri Dönülünce Liste
Model, iskeletteki iki denetleyiciyi ve yığıtı saf Swift ile kurar. Model, kodun ekrana ne yazdığını kurar; çerçevenin kendisi değildir. Gezinme denetleyicisi yalnız bir ad listesi tutar, çünkü ölçülen şey yığıtın kendisi değil, yığıt değişince ekranların ne yazdığıdır. Geri çıkarma ile didMove(toParent:) çağrısının sırası dersin seçimidir.
Olaylar sırayla şunlardır: liste yüklenir ve bir kişi seçilir, ayrıntı itilir ve yüklenir, ayrıntı geri çıkarılır.
// main.swift — liste, detay, geri cikarma
final class ContactStore {
private(set) var values: [String: String] = [:]
func update(_ key: String, _ value: String) { values[key] = value }
func remove(_ key: String) { values[key] = nil }
}
final class ContactNavigation {
private(set) var stack: [String] = []
func push(_ name: String) { stack.append(name) }
func pop() { _ = stack.popLast() }
}
final class ContactListController {
let store: ContactStore
let screen = Screen("ContactListController")
init(store: ContactStore) { self.store = store }
func viewDidLoad() {
screen.set("count", store.values["count"] ?? "")
}
func select(_ name: String) {
store.update("selected", name)
screen.set("selected", name)
}
}
final class ContactDetailController {
let store: ContactStore
let screen = Screen("ContactDetailController")
init(store: ContactStore) { self.store = store }
func viewDidLoad() {
screen.set("selected", store.values["selected"] ?? "")
screen.set("phone", store.values["phone"] ?? "")
}
func didMove(toParent parent: ContactNavigation?) {
if parent == nil { store.remove("selected") }
}
}
let store = ContactStore()
store.update("count", "2")
store.update("phone", "555-0100")
let navigation = ContactNavigation()
let list = ContactListController(store: store)
navigation.push("ContactListController")
list.viewDidLoad()
list.select("Ayse")
let chosen = check("secildi", model: store.values, screen: list.screen, keys: ["selected", "count"])
let detail = ContactDetailController(store: store)
navigation.push("ContactDetailController")
detail.viewDidLoad()
let pushed = check("detay-itildi", model: store.values, screen: detail.screen, keys: ["selected", "phone"])
navigation.pop()
detail.didMove(toParent: nil)
let popped = check("geri-cikarildi", model: store.values, screen: list.screen, keys: ["selected", "count"])
countVerdicts(chosen + pushed + popped)
secildi | ContactListController.selected: model=Ayse ekran=Ayse -> uyumlu secildi | ContactListController.count: model=2 ekran=2 -> uyumlu detay-itildi | ContactDetailController.selected: model=Ayse ekran=Ayse -> uyumlu detay-itildi | ContactDetailController.phone: model=555-0100 ekran=555-0100 -> uyumlu geri-cikarildi | ContactListController.selected: model=- ekran=Ayse -> artik geri-cikarildi | ContactListController.count: model=2 ekran=2 -> uyumlu uyumlu: 5 bayat: 0 eksik: 0 artik: 1
Seçim ve ayrıntı ilk iki olayda uyumlu. Üçüncü olayda ayrıntı denetleyicisi modeli doğru güncelledi: seçim modelden kalktı. Ama listenin etiketi hâlâ Ayse diyor. Satır artik çıktı: ekran bir değer gösteriyor, model o alanı taşımıyor. count alanı hiç değişmediği için aynı listede uyumlu kaldı. Sorun ayrıntı denetleyicisinin yanlış yazmasında değil, listenin hiçbir şey yazmamasındadır; modeli değiştirmek ekranı değiştirmez.
Bu sonuç, ayrıntı ekranı yığıttan çıktığında listenin yeniden görünmesine rağmen oluşur. Liste yeniden göründüğünde önceki derste gördüğümüz viewWillAppear çağrılır, ama listede o yöntemi yeniden tanımlayan ve seçimi modelle eşitleyen bir kod yoktur. Çözüm, o yöntemde seçim etiketini modelden yeniden yazmak ya da model boşsa temizlemektir. Bu dersin ölçümü çözümü ölçmez; yalnız sorunu gösterir.
GD1. Modelde seçimi kaldıran çağrı didMove(toParent:) içinde, geri çıkarmadan sonra gelir. Gerçek çerçevedeki bu iki olayın sırası ölçülmedi; sonuç sıraya bağlı değildir, çünkü liste bu iki çağrıdan hiçbirinde yazmaz.
Sık Yapılan Hatalar
- Listenin yeniden yüklendiğini varsaymak. Detaydan geri dönüldüğünde liste denetleyicisi yığıttaki yerinde bekliyordu;
viewDidLoadyeniden çağrılmaz. Değer yalnız orada yazıldıysa ekran eski kalır. - Geri çıkarmayı
viewWillDisappearile yakalamak. Bu yöntem, üstüne yeni bir denetleyici itildiğinde de çağrılır. Denetleyicinin gerçekten yığıttan çıktığını anlamak içinisMovingFromParentya dadidMove(toParent:)kullanılır. - Aynı denetleyici örneğini iki kez itmek. Çerçeve bunu desteklemez ve çalışma anında hata verir. Her itme için yeni bir örnek yaratılır.
- Yığıtta olmayan bir denetleyiciden itmek.
navigationControllernilise itme sessizce yapılmaz. - Detayın bıraktığı durumu listede temizlemeyi unutmak. Detay ekranı seçili kişiyi işaretler ve listeden dönülürken işaret silinmezse listede artık bir değer kalır. Bu dersin ölçtüğü sonuç budur.
Ölçümü Okumak
Altı satırın beşi uyumlu, biri artıktır. Artık satırı, geri çıkarmadan sonra liste ekranında kalan selected alanıdır: model o alanı artık taşımıyor, ekran hâlâ gösteriyor. Bu bir yazma eksikliği değil, silme eksikliğidir; liste denetleyicisi yığıtta yaşamaya devam ettiği için temizlik çağrısı kimseye düşmedi.
Dikkat çeken yan, bu hatanın detay ekranından kaynaklanmamasıdır. Detay denetleyicisi kendi işini yaptı ve didMove(toParent:) içinde modelden değeri kaldırdı. Ekranın modelle uyuşması, modelin değiştiği her ekranın kendi yazma çağrısına bağlıdır; model değişince yığıttaki diğer denetleyicilerin haberi olmaz. Liste ekranı yığıtta beklerken değişen modeli ancak yeniden göründüğünde okuyabilir, ve okumayı kimse yazmadıysa okumaz.
Başlık, Çubuk Düğmeleri ve Yığıtı Yeniden Kurmak
Her denetleyici, gezinme çubuğunda görünecekleri navigationItem üzerinden tanımlar: title çubuğun başlığını, rightBarButtonItem sağdaki düğmeyi verir. Geri düğmesinin başlığı, bir önceki denetleyicinin başlığından gelir. Bu ayarlar viewDidLoad içinde yapılır; denetleyici yığıtta kaldığı sürece bir kez yazılmaları yeter.
import UIKit
final class ContactDetailController: UIViewController {
let store: ContactStore
let name: String
init(store: ContactStore, name: String) {
self.store = store
self.name = name
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = name
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil {
store.remove("selected")
}
}
}
Yığıt, tek bir itme ve geri çıkarmadan fazlasını da yapabilir. popToRootViewController kök denetleyiciye kadar hepsini çıkarır; viewControllers dizisi yığıtın tamamını verir; setViewControllers yığıtı bütünüyle yeniden kurar. Bunların her biri, çıkan denetleyicilerin yaşam döngüsü çağrılarını alması anlamına gelir. Birden çok denetleyiciyi birden çıkaran bir çağrıdan sonra, hepsinin bıraktığı durumun temizlendiğinden emin olmak gerekir.
İki ekranın aynı veriyi göstermesi için çoğu kez ortak bir model nesnesi verilir: liste ve detay aynı depoya bakar. Bu, ekranların aynı kaynaktan okuyacağını garanti eder, ama okuma çağrısının ne zaman yapılacağını garanti etmez. Dersin ölçümü bu boşluğu gösterir.
Bir de şu ayrıntı var: pushViewController çağrısı yığıta ekler, ama geçişin görsel olarak bitmesini beklemez. Çağrıdan hemen sonra yazılan kod, yeni denetleyicinin henüz görünmediği bir anda çalışır. Bu yüzden yeni ekranın içeriğine bağlı işler, yeni denetleyicinin kendi yaşam döngüsü yöntemlerine bırakılır.
Sınırlayıcı Ölçüm
Bu ölçümün iki sınırı vardır. Birincisi: itilen ayrıntı yeni bir Screendir ve henüz bir şey yazmamıştır. Çekirdek bu ekranı viewDidLoad öncesinde denetlerse model bir değer taşıdığı için eksik der. Bu bir sapma değil, ekranın henüz kurulmamış olmasıdır. İkincisi: geri çıkarılan ekran yığıttan ayrılır ve artık denetlenmez; ölçüm yalnız görünür ekranlara bakar.
// main.swift — itilen ekran ve cikarilan ekran
final class ContactStore {
private(set) var values: [String: String] = [:]
func update(_ key: String, _ value: String) { values[key] = value }
}
final class ContactNavigation {
private(set) var stack: [String] = []
func push(_ name: String) { stack.append(name) }
func pop() { _ = stack.popLast() }
}
final class ContactDetailController {
let store: ContactStore
let screen = Screen("ContactDetailController")
init(store: ContactStore) { self.store = store }
func viewDidLoad() {
screen.set("selected", store.values["selected"] ?? "")
screen.set("phone", store.values["phone"] ?? "")
}
}
let store = ContactStore()
store.update("selected", "Ayse")
store.update("phone", "555-0100")
let navigation = ContactNavigation()
navigation.push("ContactListController")
let detail = ContactDetailController(store: store)
navigation.push("ContactDetailController")
let before = check("itildi-yazmadan-once", model: store.values, screen: detail.screen, keys: ["selected", "phone"])
detail.viewDidLoad()
let after = check("itildi-yazdiktan-sonra", model: store.values, screen: detail.screen, keys: ["selected", "phone"])
navigation.pop()
print(navigation.stack.joined(separator: ", "))
countVerdicts(before + after)
itildi-yazmadan-once | ContactDetailController.selected: model=Ayse ekran=- -> eksik itildi-yazmadan-once | ContactDetailController.phone: model=555-0100 ekran=- -> eksik itildi-yazdiktan-sonra | ContactDetailController.selected: model=Ayse ekran=Ayse -> uyumlu itildi-yazdiktan-sonra | ContactDetailController.phone: model=555-0100 ekran=555-0100 -> uyumlu ContactListController uyumlu: 2 bayat: 0 eksik: 2 artik: 0
Kural ekranın ne zaman kurulmuş sayılacağını bilmez. İtilen ekran yazmadan önce eksik, yazdıktan sonra uyumlu çıkar; bu yüzden ders, ekranı kurulum çağrısından sonra denetler. Geri çıkarma sonunda yığıtta yalnız liste kalır. Çıkarılan ayrıntı ekranı bir daha denetlenmez, çünkü artık görünür bir ekran değildir.
Özet
- Gezinme denetleyicisi ekranları bir yığıtta tutar; itme yığıta ekler, geri çıkarma üsttekini kaldırır.
- Altta kalan ekran, üstteki ekran modeli değiştirdiğinde kendiliğinden güncellenmez.
- Modelden kalkan bir seçim, listenin etiketinde hâlâ görünüyorsa artıktır.
- İtilen ekran kurulum çağrısı sırasında modele göre uyumlu yazıyor; denetlenmesi bu çağrıdan sonra anlamlıdır.
- Çıkarılan ekran yığıttan ayrılır ve ölçümün dışında kalır.
- Bu dersin uyumu: artık.
Sonraki Adım
Gezinme denetleyicisi yeni ekranı yığıta ekler. Her ekran yığıta eklenmez: bazıları mevcut ekranın üzerine açılır ve kapanınca yerine bir şey bırakmaz. Sıradaki derste sunum biçimlerini ele alacağız ve bir not penceresinde girilen değerin altta kalan ekrana ulaşıp ulaşmadığını ölçeceğiz.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.