Categories
iOS Swift SwiftUI

Tab view in SwiftUI

TabView is a container view which enables navigating between multiple flows by selecting one of the items on the tab bar. Tapping on a tab item replaces the visible view with a view associated with the item. Tab view is set up by creating views which have tab items attached. Tab items are created with the tabItem() view modifier, which supports setting a text and an image. In addition, there is a badge() view modifier if we would like to show a badge on top of the item.

Tab view also supports selection handling. Selection handling is needed when we need to programmatically control which tab is selected. For that, we’ll need to choose a type which represents the selection. The only requirement is that the type is Hashable. Therefore, we can use an enum with raw values and have a clear and readable representation of tabs. Next, all the views managed the tab view need to have a tag set with one of the enum cases. Then we can create a binding with the selection type and pass it into TabView and SwiftUI will select the tab view item which has a tag equal to the selection. Just to reiterate that we can use any other type for representing the selection, as long as it conforms to Hashable. Could be just integers as well.

struct ContentView: View {
@State private var selection: Tab = .schedule
enum Tab: Int {
case conversations, schedule, settings
}
var body: some View {
TabView(selection: $selection) {
ConversationListView()
.badge(1)
.tabItem {
Image(systemName: "bubble.left.and.bubble.right.fill")
Text("Messages")
}
.tag(Tab.conversations)
ScheduleView()
.tabItem {
Image(systemName: "clock.fill")
Text("Schedule")
}
.tag(Tab.schedule)
SettingsView()
.tabItem {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.tag(Tab.settings)
}
}
}
view raw TabView.swift hosted with ❤ by GitHub
iOSsimulator with an app which shows tab view with 3 items: messages with a badge showing 1, schedule, and settings.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Foundation Generics iOS macOS Swift

Persistent reusable container for item collections in Swift

Let’s build a container where we can store collections of items conforming to a protocol. All the collections are identified by a case in enum. For making the container reusable, we’ll use protocols as requirements on keys and items in collections. Moreover, the container should be archivable and unarchivable.

Creating a reusable container

Container’s implementation wraps a dictionary and adds methods for conveniently adding an item for key. Key must implement Hashable and RawRepresentable: then it can be used in Dictionary and converting it to representation suitable for storing on disk.

Every item needs to implement ContainerItem protocol what requires to implement methods used when archiving and unarchiving the item. Thanks to Codable protocol in Swift, it is very simple to transform the item to data and back. ContainerItem provides default implementations for its own methods when the type is conforming to Codable. Therefore, when some type wants to implement ContainerItem, then it only needs to conform to ContainerItem and Codable and default implementations will do the rest.

final class Container<Key: Hashable & RawRepresentable> {
private var storage = [Key: [ContainerItem]]() {
didSet {
didChange()
}
}
init(content: [Key: [ContainerItem]] = [:]) {
storage = content
}
func add(_ item: ContainerItem, key: Key) {
if var current = storage[key] {
current.append(item)
storage[key] = current
}
else {
storage[key] = [item]
}
}
func items<T: ContainerItem>(forKey key: Key) -> [T] {
guard let all = storage[key] else { return [] }
return all as! [T]
}
var didChange: () -> Void = {}
}
protocol ContainerItem {
init?(jsonData: Data)
var jsonDataRepresentation: Data { get }
}
extension ContainerItem where Self: Codable {
init?(jsonData: Data) {
guard let object = try? JSONDecoder().decode(Self.self, from: jsonData) else { return nil }
self = object
}
var jsonDataRepresentation: Data {
return try! JSONEncoder().encode(self)
}
}
Reusable container storing collections of items

Archiving and unarchiving the container and it’s content

Let’s first extend the container with write method. As enum cases are used as keys in dictionary, then let’s implement write method for enums what have String as RawValue. (what should be a preferred way in this use case as its provides the most readable representation of the key). We can then map dictionary entries so that key is converted to String and value to array of JSON data objects. NSKeyedArchiver provides a simple way of storing Dictionary with archivable types (like String and array of Data).

For initialising the container from data on disk, we need to make sure that we convert JSON data back to the correct type. Therefore we can extend the container for this specific enum case and converting data back to the correct type. When using enums it is easy to switch over the possible cases and then converting list of data objects to list of known types.

struct EventItem: ContainerItem, Codable {
let date: Date
let title: String
let description: String
}
struct NoteItem: ContainerItem, Codable {
let text: String
}
enum CalendarKeys: String {
case homeEvents, workEvents, notes
}
extension Container where Key == CalendarKeys {
convenience init(contentsOfURL url: URL) throws {
let data = try Data(contentsOf: url)
let contents = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [Key.RawValue: [Data]]
let converted = contents.compactMap({ (keyValuePair) -> (Key, [ContainerItem])? in
guard let key = Key(rawValue: keyValuePair.key) else { return nil }
switch key {
case .homeEvents, .workEvents:
return (key, keyValuePair.value.compactMap({ EventItem(jsonData: $0) }))
case .notes:
return (key, keyValuePair.value.compactMap({ NoteItem(jsonData: $0) }))
}
})
self.init(content: Dictionary(uniqueKeysWithValues: converted))
}
}
extension Container where Key.RawValue == String {
func write(to url: URL) throws {
let converted = storage.map { (keyValuePair) -> (String, [Data]) in
return (keyValuePair.key.rawValue, keyValuePair.value.map({ $0.jsonDataRepresentation }))
}
let data = try NSKeyedArchiver.archivedData(withRootObject: Dictionary(uniqueKeysWithValues: converted), requiringSecureCoding: false)
try data.write(to: url, options: .atomicWrite)
}
}
Providing methods for archiving and unarchiving

Summary

Wrapping dictionary with another type can be useful inmany cases where we have a known list of keys. Specialising generic types is an efficient way of adding more features to it and keeping type information intact. Thanks to Codable protocol we were able to make types archivable and unarchivable.

let container = Container<CalendarKeys>()
let event1 = EventItem(date: Date(), title: "title1", description: "description1")
container.add(event1, key: .homeEvents)
let event2 = EventItem(date: Date(), title: "title2", description: "description2")
container.add(event2, key: .workEvents)
let note1 = NoteItem(text: "text3")
container.add(note1, key: .notes)
let homeEvents: [EventItem] = container.items(forKey: .homeEvents)
let workEvents: [EventItem] = container.items(forKey: .workEvents)
let notes: [NoteItem] = container.items(forKey: .notes)
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("Test")
do {
try container.write(to: url)
}
catch {
print(error as NSError)
}
do {
let restoredContainer = try Container<CalendarKeys>(contentsOfURL: url)
let homeEvents: [EventItem] = restoredContainer.items(forKey: .homeEvents)
let workEvents: [EventItem] = restoredContainer.items(forKey: .workEvents)
let notes: [NoteItem] = restoredContainer.items(forKey: .notes)
print("Home events: ", homeEvents)
print("Work events: ", workEvents)
print("Notes: ", notes)
}
catch {
print(error as NSError)
}
Example usage of the container

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example

PersistentGenericContainer (Xcode 11.1)