Categories
iOS Swift UIKit

Presenting sheets with UISheetPresentationController

Another welcomed change what WWDC’21 brought us was UISheetPresentationController. The sheet presentation controller makes it extremely easy to present sheets like the ones which just take a half of the screen. It allows us to configure the sheet size with detents where there are currently two of them: medium and large. The medium detent means that the sheet takes about the half of the screen height, and the large detent means that the full screen is covered with the sheet. Additionally, we can configure some other visual aspects like corner radius and if the grabber should be visible. The detents property is an array, and we can set both medium and large to it, which enables to dynamically change the size either by dragging or controlling the size programmatically by setting the selectedDetentIdentifier property. And if we would like to animate the change, then the property needs to be set within the animateChanges closure.

let sheetViewController = SheetViewController()
if let sheetController = sheetViewController.sheetPresentationController {
sheetController.detents = [.medium(), .large()]
sheetController.preferredCornerRadius = 4
sheetController.prefersGrabberVisible = true
}
present(sheetViewController, animated: true, completion: nil)
view raw Sheet.swift hosted with ❤ by GitHub

Example Project

SheetPresentationController (Xcode 13.1)

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
iOS Swift UIKit

Getting started with UIButton.Configuration on iOS

UIButton.Configuration API was introduced in WWDC’21 with a goal of making it easier to create buttons with many styles, sizes, colours etc. Configurations API comes with functions for creating common button configurations: filled(), bordered(), borderedProminent(), borderedTinted(), borderless(), plain(), gray(), tinted(). These configurations can be further customized. One benefit of the API is that it will take care of different states of the button like highlighted, disabled, hovered and so on and applies different styling based on the state. Most of the time that is all we needed, but additionally we can also adjust styling for different states as well through the configurationUpdateHandler().

The basic usage of the API involves in creating a UIButton.Configuration object and passing it to UIButton init function of setting it to UIButton’s configuration property. The button will then apply the configuration. In WWDC session it was emphasized that applying a new configuration is cheap and optimized behind the scenes therefore we should never compare the new configuration with the one currently applied on the button.

var configuration = UIButton.Configuration.filled() // or .plain() or something else
configuration.baseBackgroundColor = .systemCyan
configuration.buttonSize = .large
configuration.cornerStyle = .dynamic
configuration.showsActivityIndicator = false
configuration.image = UIImage(systemName: "pencil")
configuration.imagePlacement = .trailing
configuration.imagePadding = 6
configuration.title = "Title"
configuration.titleAlignment = .leading
configuration.titlePadding = 10
configuration.subtitle = "Subtitle"
// and more
button.configuration = configuration
// or
let button2 = UIButton(configuration: configuration, primaryAction: action)
Example of button configurations when the tint colour is set to red.

For getting a better overview on how default configurations change based on the properties, we set on UIButton.Configuration type, I built a simple preview app which takes configurations: filled(), bordered(), borderedProminent(), borderedTinted(), borderless(), plain(), gray(), tinted() and applies some modifications on these. The results are shown below.

private func configurations(forStyleIndex index: Int) -> [UIButton.Configuration] {
var configuration1 = UIButton.Configuration.filled()
configuration1.title = "Filled"
var configuration2 = UIButton.Configuration.tinted()
configuration2.title = "Tinted"
var configuration3 = UIButton.Configuration.gray()
configuration3.title = "Gray"
var configuration4 = UIButton.Configuration.plain()
configuration4.title = "Plain"
var configuration5 = UIButton.Configuration.bordered()
configuration5.title = "Bordered"
var configuration6 = UIButton.Configuration.borderedProminent()
configuration6.title = "Bordered Prominent"
var configuration7 = UIButton.Configuration.borderedTinted()
configuration7.title = "Bordered Tinted"
var configuration8 = UIButton.Configuration.borderless()
configuration8.title = "Borderless"
let all: [UIButton.Configuration] = [configuration1, configuration2, configuration3, configuration4, configuration5, configuration6, configuration7, configuration8]
.map({ original in
var configuration = original
switch index {
case 0:
break
case 1:
configuration.subtitle = "Subtitle"
case 2:
configuration.image = UIImage(systemName: "pencil")
case 3:
configuration.buttonSize = .large
case 4:
configuration.image = UIImage(systemName: "pencil")
configuration.imagePlacement = .trailing
case 5:
configuration.baseBackgroundColor = .systemBlue
case 6:
configuration.buttonSize = .mini
case 7:
configuration.showsActivityIndicator = true
case 8:
configuration.baseBackgroundColor = .systemCyan
configuration.buttonSize = .large
configuration.cornerStyle = .dynamic
configuration.showsActivityIndicator = false
configuration.image = UIImage(systemName: "pencil")
configuration.imagePlacement = .trailing
configuration.imagePadding = 6
configuration.titleAlignment = .leading
configuration.titlePadding = 10
configuration.subtitle = "Subtitle"
default:
fatalError("Implement")
}
return configuration
})
return all
}
view raw Buttons.swift hosted with ❤ by GitHub

Example Project

UIButtonConfigurationPreview (Xcode 13)

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
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
iOS macOS Swift SwiftUI

Structuring platform specific code in SwiftUI

Xcode provides a cross-platform app template in SwiftUI which can be built for macOS and iOS. This template includes a Shared folder which is meant for code used both on macOS and iOS. Many SwiftUI types and functions work both on macOS and iOS but along with developing an app we’ll encounter cases where we need to specialize per platform. When I was building a multiplatform app then the very first thing I encountered was list styles. On macOS I wanted to use a different list style than on iOS (SidebarListStyle vs GroupedListStyle). Therefore, let’s take this as an example and see what are the ways how to handle this.

Compile time checks

The first way is to add compile time checks directly to the UI code. Swift provides #if os() for branching out code which should only be compiled on the specified platform. One downside of this approach is that it can make the code pretty long in the view body when we have multiple platform specific branches. In those cases, it might make sense to have separate functions which just contain the #if os() branch. But in the example below, we just have a listStyle view modifier, which is configured differently on macOS and iOS.

struct ContentView: View {
@State var items = [1, 2, 3]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
}
}
#if os(macOS)
.listStyle(SidebarListStyle())
#else
.listStyle(GroupedListStyle())
#endif
}
}

Platform specific extensions

Another way for handling platform specific code is to create a separate files which are only included to platform targets. In this concrete case, those files contain a view extension and define a contentListStyle property which returns a list style. ContentView.swift file can access the property and when we are building for macOS then ContentView+Mac.swift is compiled and when building for iOS then ContentView+iOS.swift. I would recommend creating separate folders for platform specific code. For example, the project folders could be Shared, Mac, and iOS. Separate folders give a clear overview where the platform specific code is.

struct ContentView: View {
@State var items = [1, 2, 3]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
}
}
.listStyle(contentListStyle)
}
}
extension ContentView {
var contentListStyle: GroupedListStyle {
return GroupedListStyle()
}
}
extension ContentView {
var contentListStyle: SidebarListStyle {
return SidebarListStyle()
}
}
Project structure in Xcode with Shared, Mac, and iOS folders for managing platform specific code.
Project structure with Shared, Mac, and iOS folders.

Summary

SwiftUI code can be shared most of the time between platforms. Sometimes we’ll need to configure the shared code per platform or use platform specific APIs. Hopefully those two approaches will give some flexibility on handling such code.

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
macOS Swift SwiftUI

Sidebar layout on macOS in SwiftUI

A common UI layout on macOS has a sidebar and detail view side by side. The sidebar contains a list of items, where the selected item is displayed on the right and displays details of it. One would expect that creating such a view hierarchy in SwiftUI should be pretty easy to set up. In this post, we’ll take a look at how to create a basic view with sidebar which supports selection.

Building the layout

We’ll build a simple sample app which shows a list of fruits in the sidebar and when clicking on any of the fruits, the right pane displays the name of the fruit. Therefore, we’ll need a struct representing a fruit, a view model for storing the list of fruits, and a property for storing the selected fruit in the sidebar.

struct Fruit: Identifiable {
let id = UUID().uuidString
let name: String
}
final class ViewModel: ObservableObject {
init(fruits: [Fruit] = ViewModel.defaultFruits) {
self.fruits = fruits
self.selectedId = fruits[1].id
}
@Published var fruits: [Fruit]
@Published var selectedId: String?
static let defaultFruits: [Fruit] = ["Apple", "Orange", "Pear"].map({ Fruit(name: $0) })
}

We can create the layout with NavigationView and NavigationLink. Inside the NavigationView we’ll first add a List where each of the item in the list is represented by a NavigationLink. One of the NavigationLink initializers takes a title, tag, and selection binding. Tag is used for identifying items in the list and setting one of the tag values to the selection binding will make the sidebar to select the item programmatically. Also, we’ll need to set the list style to “sidebar” which adds the appropriate styling to it. Finally, we’ll add a Text element, which acts as a placeholder view when there is no selection. And that is all what we need to do to get going with a view with sidebar and detail pane.

struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
List {
ForEach(viewModel.fruits) { item in
NavigationLink(item.name, tag: item.id, selection: $viewModel.selectedId) {
Text(item.name)
.navigationTitle(item.name)
}
}
}
.listStyle(.sidebar)
Text("No selection")
}
}
}
A sample app with sidebar on the left showing 3 items and detail view on the right displaying the name of the selected item.
The final sample app with a sidebar.

Summary

We used NavigationView and NavigationLink to create a common layout for macOS apps, which features a sidebar with list of items and detailed view on the right. With only a bit of code, we were able to set it up.

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
Swift

Adding async await to existing completion handler based code

Xcode 13 with Swift 5.5 toolchain brings async-await to the Swift language. The aim of this blog post is not to cover everything it brings, but instead concentrate on seeing how completion handler based code can be wrapped with async await. In the end, we have reused existing code but made it available to be called from async task contexts. For demonstrating this, we’ll take my IndexedDataStore Swift package and add async await supported methods to existing completion handler based API.

The IndexedDataStore package has async methods for loading data and storing data.

func loadData<T>(forIdentifier identifier: Identifier, dataTransformer: @escaping (Data) -> T?, completionHandler: @escaping (T?) -> Void)

func storeData(_ dataProvider: @escaping () -> Data?, identifier: Identifier = UUID().uuidString, completionHandler: @escaping (Result<Identifier, Error>) -> Void) {

We can easily add async await methods to these completion hander based methods. We’ll need to use one of the withCheckedXXXContinuation functions. This allows us to add a method to IndexedDataStore class like this:

public func loadData<T>(forIdentifier identifier: Identifier, dataTransformer: @escaping (Data) -> T?) async -> T? {
return await withCheckedContinuation({ continuation in
self.loadData(forIdentifier: identifier, dataTransformer: dataTransformer) { object in
continuation.resume(returning: object)
}
})
}

What happens here is that we use the async withCheckedContinuation function, which gives us a hook into async await machinery. The function suspends the current task in hand and calls the completion handler with a continuation object. Then we can use our completion handler based async code and resume the suspended task when we are done by calling resume.

When we are dealing with completion handlers which use the Result type or provide an error value, then we’ll need to use withCheckedThrowingContinuation instead. This function provides a continuation object which supports throwing errors. This is exactly the case with the storeData function.

public func storeData(_ dataProvider: @escaping () -> Data?, identifier: Identifier = UUID().uuidString) async throws -> Identifier {
return try await withCheckedThrowingContinuation({ continuation in
self.storeData(dataProvider, identifier: identifier) { result in
continuation.resume(with: result)
}
})
}

When it comes to unit-testing then async await makes the testing code so much shorter. We can annotate test functions with the async keyword and do not need to deal with XCTestExpectations.

func testStoreAndLoadAsync() async throws {
let identifier = try await dataStore.storeData({ "Data".data(using: .utf8) }, identifier: "abc")
XCTAssertEqual(identifier, "abc")
let string = await dataStore.loadData(forIdentifier: "abc", dataTransformer: { String(decoding: $0, as: UTF8.self) })
XCTAssertEqual(string, "Data")
}

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
iOS Swift UIKit

Flow layout with self-sizing items and fixed spacing in UIKit

One of the really common layouts I have needed to implement with collection view is a simple flow layout but with fixed spacings. Apple provides us UICollectionViewFlowLayout, but the sad part is that it has dynamic spacing between items. Everything is there but not quite. Before UICollectionViewCompositionalLayout, one needs to create a subclass of the flow layout and then fixing spacings manually, which is pretty cumbersome to do. Therefore, let’s instead see what it takes to implement a simple self-sizing flow layout with fixed spacings when using UICollectionViewCompositionalLayout. The end goal is visible below, where we have a single section with 7 items.

Flow layout with fixed spacings.

UICollectionViewCompositionalLayout was created to be a flexible layout which allows building all sorts of layouts quickly. Data in that layout is divided into sections, where each section can have one or more groups of items. Grouping allows creating more complex layouts, where each group describes how items in the group are laid out in relation to each other. But in our case we have something really simple in mind, which is having self-sizing items which we can configure with NSCollectionLayoutSize and passing estimated dimensions. Then the next step is creating NSCollectionLayoutItem with that layout size and with some space around the item. The edge spacing with fixed edges gives us the wanted fixed spacing between items. After that, we’ll create NSCollectionLayoutGroup with horizontal layout direction and with a layout size which takes max width, but height is fitted based on item sizes. Creating layouts like this is so much better compared to subclassing UICollectionViewLayout and then calculating frames one by one. Down below is the configured layout object, which has fixed spacing and items are self-sizing.

extension UICollectionViewLayout {
static func fixedSpacedFlowLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .estimated(50),
heightDimension: .estimated(50)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(
leading: .fixed(8),
top: .fixed(4),
trailing: .fixed(8),
bottom: .fixed(4)
)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(100)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}
}

Example project can be found here: GitHub

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
iOS Swift UIKit

Height fitting collection view

I have numerous times needed to show some sort of collection view which adjusts its height based on the content. Most of the time it has been a dynamic list within some more complex scrollable UI. Therefore, in this post, we’ll take a look at how to set up a collection view which has its height set to the content height. On the screenshot below, we have a collection view with light grey background and two sections.

Collection view with height fitting size.

The approach for making this working is pretty simple, which involves adding a height constraint with the constant value set to collection view content height. Content height can be retrieved from the layout object. The constraint’s constant value can be updated in viewWillLayoutSubviews.

private var collectionViewHeightConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
collectionView.register(TextCollectionViewCell.self, forCellWithReuseIdentifier: Self.reuseIdentifier)
collectionView.backgroundColor = UIColor(white: 0.9, alpha: 1)
collectionView.isScrollEnabled = false
collectionViewHeightConstraint = collectionView.heightAnchor.constraint(equalToConstant: 50)
collectionViewHeightConstraint.isActive = true
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
collectionViewHeightConstraint.constant = collectionViewLayout.collectionViewContentSize.height
}

The full example collection view implementation can be seen here: FittingHeightCollectionView.

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
SignalPath Swift

Signal Path 2.6 for macOS, iOS and iPadOS

Signal Path version 2.6 is out. Signal Path now supports wav files in addition to the existing raw IQ data format. Audio recordings are now saved as wav files, and it means that audio recordings can be played with other music player apps. Additionally, some audio quality issues related to playing audio recordings were fixed along with it.

Download on the Mac App Store Download on the App Store
Categories
iOS macOS Swift SwiftUI

Image converter with AsyncSequence

WWDC’21 brought us a new protocol named AsyncSequence. As the name stands, it represents a sequence of asynchronous elements. For trying out the new API we’ll build a tiny ThumbnailSequence which takes in a list of image names and by iterating the sequence, we’ll get back scaled thumbnails for those image names one by one. The image scaling runs on a background thread.

The AsyncSequence protocol comes with two associated types: Element and AsyncIterator. Element represents the type which is produced by the sequence, and AsyncIterator is the type responsible for reproducing elements. Same as with sequences, but the main difference is that accessing each of the element is asynchronous. Therefore, for creating a custom type ThumbnailSequence which conforms to AsyncSequence we’ll, set the associated type Element to be equal to UIImage, and implement a custom iterator. ThumbnailSequence initializers takes a list of image names and also defines a max scaled image size. Additionally, we’ll take advantage of the new byPreparingThumbnail(ofSize) async method for scaling the image. Implementation of the async sequence is shown below:

struct ThumbnailSequence: AsyncSequence {
typealias AsyncIterator = Iterator
typealias Element = UIImage
var imageNames: [String]
let maxSize: CGSize
func makeAsyncIterator() -> Iterator {
return Iterator(imageNames: imageNames, maxSize: maxSize)
}
struct Iterator: AsyncIteratorProtocol {
typealias Element = UIImage
var imageNames: [String]
let maxSize: CGSize
mutating func next() async -> UIImage? {
guard !imageNames.isEmpty else { return nil }
guard let image = UIImage(named: imageNames.removeFirst()) else { return nil }
let ratio = image.size.height / maxSize.height // simplified scaling
return await image.byPreparingThumbnail(ofSize: CGSize(width: image.size.width / ratio, height: maxSize.height))
}
}
}

With a simple async sequence created, we can hook it up to a SwiftUI view. WWDC’21 also brought a new task view modifier, which is invoked when the view appears and cancelled when the view is removed. In the task view modifier we’ll loop over the sequence and one by one load UIImages which then are set to a local images array which in turn is connected to a LazyVStack. The flow we’ll get is that we are loading images one by one, and after every image load we’ll add a new item to the stack.

struct ContentView: View {
let imageNamesToLoad = ["Screenshot1", "Screenshot2", "Screenshot3", "Screenshot4", "Screenshot5", "Screenshot6", "Screenshot7", "Screenshot8", "Screenshot9", "Screenshot10"]
@State private var images = [UIImage]()
var body: some View {
ScrollView {
LazyVStack {
ForEach(images, id: \.self) { image in
Image(uiImage: image)
}
}
}
.task {
for await image in ThumbnailSequence(imageNames: imageNamesToLoad, maxSize: CGSize(width: 256, height: 256)) {
print("Loaded \(image)")
self.images.append(image)
}
}
}
}
Screenshot of a iPhone simulator showing a view with vertical list of images.
The final sample app displaying images in a vertical stack.

Summary

In this post, we took a quick look at the AsyncSequence protocol and created a pipeline which converts image names to scaled image instances one by one. After that, we connected the pipeline to a SwiftUI view.

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.