Categories
iOS Swift UIKit

Building a list view with collection view in UIKit

UICollectionViewCompositionalLayout was an important change to how we create collection view layouts. In iOS14 Apple added a new static function to this class which creates a layout object for list views. Meaning, it is very easy to create a list views which look like a table view we are familiar with. The static list() function takes a configuration object UICollectionLayoutListConfiguration which allows further to configure the appearance of the header view. For example, supplementary header views are enabled here. In this blog post, we’ll create a list view with a collection view and use cell registration and diffable data source APIs.

List view created with diffable data source and collection view.

Generating data for the list view

Firstly, we’ll generate some data types which we want to display in the list view. The aim is to represent each Palette type with one section, and each PaletteColor is a row in the section.

struct Palette: Hashable {
let name: String
let colors: [PaletteColor]
// other properties
static let fancy = Palette(name: "Fancy", colors: [
PaletteColor(name: "Red", color: .systemRed),
PaletteColor(name: "Blue", color: .systemBlue),
PaletteColor(name: "Cyan", color: .systemCyan),
PaletteColor(name: "Mint", color: .systemMint),
PaletteColor(name: "Pink", color: .systemPink),
PaletteColor(name: "Teal", color: .systemTeal),
PaletteColor(name: "Green", color: .systemGreen),
PaletteColor(name: "Brown", color: .systemBrown)
])
static let secondary = Palette(name: "Secondary", colors: [
PaletteColor(name: "Label", color: .secondaryLabel),
PaletteColor(name: "Fill", color: .secondarySystemFill)
])
}
struct PaletteColor: Hashable {
let name: String
let color: UIColor
// other properties
}
view raw ListView.swift hosted with ❤ by GitHub

Configuring a collection view instance

We’ll create the collection view instance with a layout object which is configured to display lists. We go for insetGrouped appearance and turn on header views.

final class ListViewController: UIViewController {
private func makeCollectionView() -> UICollectionView {
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .systemBackground
view.translatesAutoresizingMaskIntoConstraints = false
return view
}
private lazy var collectionView = makeCollectionView()
override func loadView() {
title = "Palettes"
view = UIView(frame: .zero)
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
viewModel.reloadContent(in: dataSource)
}
}
view raw ListView.swift hosted with ❤ by GitHub

Configuring collection view data source and cell registration

The UICollectionViewDiffableDataSource is used for managing the data and also provides cells on demand. We’ll need to define section and item types when creating the data source. With the data we want to display, we’ll use String as a section type and PaletteColor as the item type when the section is just the name of the Palette’s name. In addition, we’ll use the cell and supplementary view registration APIs which keep the logic of creating different cells with a model object in the same place, which I find it to be really nice. Cells and supplementary views we’ll configure using the content configuration APIs which describe the data the cell or supplementary view displays. For list views, there is a specialized UIListContentConfiguration type which supports a variety of appearances. In many cases we do not need any custom cell classes at all since UIListContentConfiguration and UICollectionViewListCell takes care of it for us.

final class ListViewController: UIViewController {
let viewModel: ViewModel
init(viewModel: ViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func makeDataSource() -> UICollectionViewDiffableDataSource<String, PaletteColor> {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, PaletteColor> { [viewModel] cell, indexPath, paletteColor in
var configutation = UIListContentConfiguration.cell()
configutation.image = viewModel.cellImage(for: paletteColor)
configutation.text = viewModel.cellTitle(for: paletteColor)
cell.contentConfiguration = configutation
}
let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [viewModel] supplementaryView, elementKind, indexPath in
var configutation = UIListContentConfiguration.groupedHeader()
configutation.text = viewModel.headerTitle(in: indexPath.section)
supplementaryView.contentConfiguration = configutation
}
let dataSource = UICollectionViewDiffableDataSource<String, PaletteColor>(collectionView: collectionView, cellProvider: { collectionView, indexPath, paletteColor in
collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: paletteColor)
})
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
return dataSource
}
private lazy var dataSource = makeDataSource()
}
view raw ListView.swift hosted with ❤ by GitHub

Applying a snapshot

The final missing piece is creating a snapshot and applying it to the collection view data source which then tells the collection view what to render. If we would generate a new snapshot with slightly different data then the collection view only renders the changes between snapshots. No need to do this manually on our own.

extension ListViewController {
@MainActor final class ViewModel {
let palettes: [Palette]
init(palettes: [Palette]) {
self.palettes = palettes
}
func reloadContent(in dataSource: UICollectionViewDiffableDataSource<String, PaletteColor>) {
var snapshot = NSDiffableDataSourceSnapshot<String, PaletteColor>()
snapshot.appendSections(palettes.map(\.name))
palettes.forEach({ palette in
snapshot.appendItems(palette.colors, toSection: palette.name)
})
dataSource.apply(snapshot)
}
}
}
view raw ViewModel.swift hosted with ❤ by GitHub

Summary

Diffable data sources with new cell registration APIs make a huge difference in how we implement collection views. Although it might take a bit of time to see how all the new APIs work together, I do not want to go back. Please check the example project for full code.

Example project

UIKitExampleDiffableListView (Xcode 13.3.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 SwiftUI UIKit

UIKit navigation with SwiftUI views

Recently I was asked a question about creating an app which has SwiftUI views but no navigation logic in it. Instead, UIKit controls how views are presented. It is a fair question since SwiftUI views have navigation support, but not everything is there if we need to support previous iOS versions as well, or we have a case of an app which have both UIKit and SwiftUI views. Therefore, let’s take a look at on one approach, how to handle navigation on the UIKit side but still use SwiftUI views.

UIHostingController presenting SwiftUI view

SwiftUI views are presented in UIKit views with UIHostingController which just takes in the SwiftUI view. UIHostingController is a UIViewController subclass, therefore it can be used like any other view controller in the view hierarchy. For getting things started, let’s configure SceneDelegate to use an object named FlowCoordinator which will handle navigation logic and then ask it to present a simple SwiftUI view.

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private lazy var flowController = FlowCoordinator(window: window!)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
flowController.showRootView()
window?.makeKeyAndVisible()
}
}
final class FlowCoordinator {
private let window: UIWindow
init(window: UIWindow) {
self.window = window
}
func showRootView() {
let swiftUIView = ContentView()
let hostingView = UIHostingController(rootView: swiftUIView)
window.rootViewController = UINavigationController(rootViewController: hostingView)
}
}
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, World!")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.green)
}
}
A simple iOS app showing a green view which is rendered with SwiftUI but presented with UIKit
A simple app showing a green view which is rendered with SwiftUI but presented with UIKit.

Inserting the FlowCoordinator into SwiftUI view

The next step is that we want to allow SwiftUI view to control what is presented on the screen. For example, let’s add a button to the SwiftUI view which should present a sheet with another SwiftUI view. The button action needs to be able to talk to the flow coordinator, which controls what is presented on the screen. One way to insert the FlowCoordinator into SwiftUI environment is by conforming to ObservableObject and using the environmentObject() view modifier. Alternative is using EnvironmentValues and defining a key. For more information please check Injecting dependencies using environment values and keys in SwiftUI.

final class FlowCoordinator: ObservableObject {}
let swiftUIView = ContentView()
.environmentObject(self) // self is FlowCoordinator
struct ContentView: View {
@EnvironmentObject var flowController: FlowCoordinator
var body: some View {
VStack(spacing: 8) {
Text("Root view")
Button("Present Sheet", action: flowController.showDetailView)

Presenting a sheet

The sheet presentation code goes into the FlowCoordinator and as an example we show a DetailView which has a button for dismissing itself. Yet again, SwiftUI view just passes the handling to the FlowCoordinator.

final class FlowCoordinator: ObservableObject {
//
func showDetailView() {
let detailView = DetailView()
.environmentObject(self)
let viewController = UIHostingController(rootView: detailView)
window.rootViewController?.present(viewController, animated: true, completion: nil)
}
func closeDetailView() {
// Needs to be more sophisticated later when there are more views
window.rootViewController?.presentedViewController?.dismiss(animated: true, completion: nil)
}
}
struct DetailView: View {
@EnvironmentObject var flowController: FlowCoordinator
var body: some View {
VStack(spacing: 8) {
Text("Detail view content")
Button("Dismiss", action: flowController.closeDetailView)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.orange)
}
}
A simple iOS app showing an orange sheet which is rendered with SwiftUI but presented with UIKit
A simple app showing an orange sheet which is rendered with SwiftUI but presented with UIKit.

Summary

We created a simple sample app which uses UIKit navigation logic but renders views with SwiftUI views. This kind of setup could be useful for apps which mix UIKit and SwiftUI. But I believe that even in case of that we could still use SwiftUI navigation in sub-flows but could keep using this approach for handling root view navigation.

Example Project

UIKitNavigationWithSwiftUIViews (Xcode 13.2.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 SwiftUI UIKit

Using a multi component picker in a SwiftUI form

SwiftUI has a Picker view available with multiple different styles. One example of when it falls short is when we want to use a multi component picker with wheel style. One way how to try to achieve this is using a HStack with two Picker views, but it does not work very well, especially when trying to show it inside a Form view. So what else we can do? If something can’t be done in SwiftUI then we can use UIKit instead.

In my case, I wanted to create a picker which allows picking a date duration. It would have one wheel for selecting a number and the other wheel for selecting either days, weeks or months.

Screenshot of a SwiftUI form with a two component wheel picker where left wheel selects a number and right wheel selects days, weeks or months.

Firstly, let’s create a tiny struct which is going to hold the state of this picker. It needs to store a numeric value and the unit: days, weeks, months. Let’s name it as DateDuration. Since we want to iterate over the DateDuration.Unit, we’ll conform it to CaseIterable protocol.

struct DateDuration {
let value: Int
let unit: Unit
enum Unit: String, CaseIterable {
case days, weeks, months
}
}

UIPickerView in UIKit can do everything we want, therefore we’ll need to wrap it into a SwiftUI view. This can be done by creating a new type which conforms to UIViewRepresentable protocol. Also, we need a binding which holds the value of the current selection: when the user changes it, the binding communicates the changes back and vice-versa. Additionally, we’ll add properties for configuring values and units. UIPickerView us created and configured in the makeUIView(context:) function. UIPickerView is driven by a data source and a delegate, which means we require a coordinator object as well. Coordinator is part of the UIViewRepresentable protocol.

struct DateDurationPicker: UIViewRepresentable {
let selection: Binding<DateDuration>
let values: [Int]
let units: [DateDuration.Unit]
func makeUIView(context: Context) -> UIPickerView {
let pickerView = UIPickerView(frame: .zero)
pickerView.translatesAutoresizingMaskIntoConstraints = false
pickerView.delegate = context.coordinator
pickerView.dataSource = context.coordinator
return pickerView
}
//
}

Coordinator is created in the makeCoordinator() function. It is going to do most of the work by providing data to the UIPickerView and handling the current selection. Therefore, we’ll store the selection binding, values, and units in the Coordinator class as well.

struct DateDurationPicker: UIViewRepresentable {
//
func makeCoordinator() -> Coordinator {
return Coordinator(selection: selection, values: values, units: units)
}
final class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
let selection: Binding<DateDuration>
let values: [Int]
let units: [DateDuration.Unit]
init(selection: Binding<DateDuration>, values: [Int], units: [DateDuration.Unit]) {
self.selection = selection
self.values = values
self.units = units
}
//
}
}

The last missing piece is implementing UIPickerViewDataSource and UIPickerViewDelegate methods in the Coordinator class. This is pretty straight-forward to do. We’ll need to display two components where the first component is the list of values and the second component is the unit: days, weeks, months. When the user selects a new value, we’ll change the DateDuration value of the binding.

func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 2
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return component == 0 ? values.count : units.count
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
if component == 0 {
return "\(values[row])"
}
else {
return units[row].rawValue
}
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
let valueIndex = pickerView.selectedRow(inComponent: 0)
let unitIndex = pickerView.selectedRow(inComponent: 1)
selection.wrappedValue = DateDuration(value: values[valueIndex], unit: units[unitIndex])
}

Finally, let’s hook it up in an example view.

struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
Form {
Section {
//
DateDurationPicker(
selection: $viewModel.selection,
values: Array(1..<100),
units: DateDuration.Unit.allCases
)
//
}
}
.navigationTitle("Reminders")
}
}
}
extension ContentView {
final class ViewModel: ObservableObject {
@Published var selection = DateDuration(value: 1, unit: .days)
//
}
}

Example Project

SwiftUIDateDurationPicker (GitHub, Xcode 13.2.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
CoreData iOS macOS Swift SwiftUI UIKit

Most visited blog posts in 2021

Another year spent on writing blog posts. Let’s see which were the most read blog posts in 2021.

Top 10 written on any year

  1. Opening hyperlinks in UILabel on iOS (December 20, 2020)
  2. Using an image picker in SwiftUI (November 22, 2020)
  3. Using SwiftUI previews for UIKit views (June 7, 2020)
  4. Resizing UIImages with aspect fill on iOS (October 25, 2020)
  5. Alert and LocalizedError in SwiftUI (March 1, 2020)
  6. Adding custom attribute to NSAttributedString on iOS (November 10, 2019)
  7. Using CoreData with SwiftUI (January 19, 2020)
  8. Text input in UITableView (November 4, 2018)
  9. Adding SwiftLint to a project in Xcode (September 13, 2020)
  10. @StateObject and MVVM in SwiftUI (August 2, 2020)

Top 3 written in 2021

  1. Measurement, Unit, Dimension, and MeasurementFormatter on iOS (January 18, 2021)
  2. Running tests in Swift package with GitHub actions (April 26, 2021)
  3. Exploring AttributedString and custom attributes (June 21, 2021)
  4. Code coverage for Swift Packages with Fastlane (April 12, 2021)
  5. Sidebar layout on macOS in SwiftUI (September 13, 2021)

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

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

Most visited blog posts in 2020

I have been writing a new blog post at least every second week and therefore in 2020 I have published total of 27 blog posts. It is time to take a look back on the 2020 and see which blog posts were the most popular ones. I am just looking at the total count of views per blog post.

Most read posts in 2020

  1. Using CoreData with SwiftUI (January 19, 2020)
  2. Validating string in TextField with NumberFormatter in SwiftUI (April 26, 2020)
  3. Scanning text using SwiftUI and Vision on iOS (July 7, 2019)
  4. Adding custom attribute to NSAttributedString on iOS (November 10, 2019)
  5. Fetching and displaying data on Watch app in SwiftUI (February 16, 2020)
  6. Animating view transitions in SwiftUI (December 8, 2019)
  7. Text input in UITableView (November 4, 2018)
  8. NavigationLink and presentationMode environment value property for dismissing a view in SwiftUI (September 29, 2019)
  9. MVVM in SwiftUI (January 5, 2020)
  10. Dynamic user notification on Apple Watch with SwiftUI (February 2, 2020)

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

Opening hyperlinks in UILabel on iOS

UILabel supports displaying attributed strings with link attributes but what it does not support is allowing to tap on hyperlinks and opening those in Safari. An Alternative way is using an UITextView which does support opening hyperlinks but on the other hand it is a more heavy view component and therefore might not be the best choice when we just need to display some text with hyperlinks. This time lets create a simple UILabel subclass which adds support for opening hyperlinks and custom hyperlink styles.

Creating NSAttributedStrings with hyperlinks

Before we jump into implementing an UILabel subclass HyperlinkLabel, let’s first take a look on how to create a NSAttributedString with hyperlinks. In the example app we will have several labels with different configurations: default and custom link styles and left, centre, right text alignments. UITextView has a linkTextAttributes property but in our UILabel subclass we’ll need to implement custom link styling ourselves. The approach we are going to take is creating a new NSAttributedString.Key value named hyperlink and adding text attributes when the attribute is present.

extension NSAttributedString.Key {
static let hyperlink = NSAttributedString.Key("hyperlink")
}

Let’s now create a convenience method which creates NSAttributedString and sets it to the HyperlinkLabel.

private extension HyperlinkLabel {
static func banner(withAlignment alignment: NSTextAlignment, customStyling: Bool, tapHandler: @escaping (URL) -> Void) -> HyperlinkLabel {
let attributedString = NSMutableAttributedString(string: "Check this webpage: %0$@. Link to %1$@ on the App Store. Finally link to %2$@.")
let replacements = [("Augmented Code", URL(string: "https://augmentedcode.io")!),
("SignalPath", URL(string: "https://geo.itunes.apple.com/us/app/signalpath/id1210488485?mt=12")!),
("GitHub", URL(string: "https://github.com/laevandus")!)]
replacements.enumerated().forEach { index, value in
let linkAttribute: NSAttributedString.Key = customStyling ? .hyperlink : .link
let attributes: [NSAttributedString.Key: Any] = [
linkAttribute: value.1
]
let urlAttributedString = NSAttributedString(string: value.0, attributes: attributes)
let range = (attributedString.string as NSString).range(of: "%\(index)$@")
attributedString.replaceCharacters(in: range, with: urlAttributedString)
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: attributedString.length))
let label = HyperlinkLabel()
label.attributedText = attributedString
label.translatesAutoresizingMaskIntoConstraints = false
label.didTapOnURL = tapHandler
return label
}
}

The final NSAttributedString is created by first initializing a mutable version with a format string. The format string follows the familiar format specifiers like we used with NSString and String APIs when dealing with re-orderable arguments. Format specifiers are replaced with new instances of NSAttributedStrings where the string value equals to a hyperlink name and the URL value is stored on the NSAttributedString.Key.link or NSAttributedString.Key.hyperlink attribute. The former gives us the default link style defined by Apple and the latter our custom link style.

class ViewController: UIViewController {
@IBOutlet weak var stackView: UIStackView!
private lazy var resultLabel: UILabel = .sectionTitle("Tap on the label…")
override func viewDidLoad() {
super.viewDidLoad()
stackView.addArrangedSubview(resultLabel)
stackView.addArrangedSubview(UILabel.sectionTitle("Left alignment"))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .left, customStyling: false, tapHandler: didTap))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .left, customStyling: true, tapHandler: didTap))
stackView.addArrangedSubview(UILabel.sectionTitle("Center alignment"))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .center, customStyling: false, tapHandler: didTap))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .center, customStyling: true, tapHandler: didTap))
stackView.addArrangedSubview(UILabel.sectionTitle("Right alignment"))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .right, customStyling: false, tapHandler: didTap))
stackView.addArrangedSubview(HyperlinkLabel.banner(withAlignment: .right, customStyling: true, tapHandler: didTap))
}
private func didTap(_ url: URL) {
// In the example app we just print the result and do not open it in Safari
resultLabel.text = "Did tap on: \(url)"
}
}

Creating HyperlinkLabel which supports tapping on hyperlinks

UILabel does not provide access to its text layout and therefore it is not possible to know which hyperlink was tapped. For finding the tapped hyperlink we’ll need to use our own NSLayoutManager, NSTextStorage, and NSTextContainer. If we configure those properly we can figure out which character was tapped and therefore if it was part of the hyperlink. If it is, then we can let UIApplication to open the tapped URL. Let’s contain this in a function which gives us a configured NSTextStorage.

private func preparedTextStorage() -> NSTextStorage? {
guard let attributedText = attributedText, attributedText.length > 0 else { return nil }
// Creates and configures a text storage which matches with the UILabel's configuration.
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: bounds.size)
textContainer.lineFragmentPadding = 0
let textStorage = NSTextStorage(string: "")
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
textContainer.lineBreakMode = lineBreakMode
textContainer.size = textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines).size
textStorage.setAttributedString(attributedText)
return textStorage
}

Now we can use this function when handling UITouch events. We’ll add another private function which will take current UITouches and figure out which hyperlink was tapped. The general flow consists of creating a NSTextStorage with the function we just defined, then asking from the NSLayoutManager which character index was tapped. NSLayoutManager returns the closest character index of the touch. Therefore, we’ll need to go one step deeper and ask for the actual bounding rectangle of the glyphs representing the character and then verifying if the touch location was inside the glyph’s bounding rectangle. This is important when dealing with different text alignments and when tapping on the free space around characters. After figuring out which character was tapped we’ll need to check for the hyperlink attributes. If the tapped character has either attribute set, then we can conclude that we tapped on a hyperlink. This function can then be called in touchesEnded and if we tapped on a hyperlink, then we can open it. One thing to note is that userInteractionEnabled needs to be set to true before UILabel can handle touch events.

private func url(at touches: Set<UITouch>) -> URL? {
guard let attributedText = attributedText, attributedText.length > 0 else { return nil }
guard let touchLocation = touches.sorted(by: { $0.timestamp < $1.timestamp } ).last?.location(in: self) else { return nil }
guard let textStorage = preparedTextStorage() else { return nil }
let layoutManager = textStorage.layoutManagers[0]
let textContainer = layoutManager.textContainers[0]
let characterIndex = layoutManager.characterIndex(for: touchLocation, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
guard characterIndex >= 0, characterIndex != NSNotFound else { return nil }
// Glyph index is the closest to the touch, therefore also validate if we actually tapped on the glyph rect
let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: characterIndex, length: 1), actualCharacterRange: nil)
let characterRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
guard characterRect.contains(touchLocation) else { return nil }
// Link styled by Apple
if let url = textStorage.attribute(.link, at: characterIndex, effectiveRange: nil) as? URL {
return url
}
// Custom link style
return textStorage.attribute(.hyperlink, at: characterIndex, effectiveRange: nil) as? URL
}
var didTapOnURL: (URL) -> Void = { url in
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: { success in
if success {
print("Opened URL \(url) successfully")
}
else {
print("Failed to open URL \(url)")
}
})
}
else {
print("Can't open the URL: \(url)")
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if let url = self.url(at: touches) {
didTapOnURL(url)
}
else {
super.touchesEnded(touches, with: event)
}
}

Next thing we need to do is overriding attributedText property and handling the custom style of our custom hyperlink attribute. If the attributed text has this attribute set, then we will apply custom hyperlink text attributes. The same way as Apple’s link attribute works, when the attributed string gets displayed then custom styling is used. Secondly, we’ll set the UILabel’s font to the attributed string’s ranges which do not have a font attribute set. UILabel internally use UILabel’s font when font attributes are not set, so we want to force the same behaviour when the stored attributed string is set to our own NSTextStorage. If we do not do this, then NSAttributedString just uses its default font and the displayed string is not going to be equal to the string set to NSTextStorage. This in turn will lead to invalid character index calculations because fonts are different.

override var attributedText: NSAttributedString? {
get {
return super.attributedText
}
set {
super.attributedText = {
guard let newValue = newValue else { return nil }
// Apply custom hyperlink attributes
let text = NSMutableAttributedString(attributedString: newValue)
text.enumerateAttribute(.hyperlink, in: NSRange(location: 0, length: text.length), options: .longestEffectiveRangeNotRequired) { (value, subrange, _) in
guard let value = value else { return }
assert(value is URL)
text.addAttributes(hyperlinkAttributes, range: subrange)
}
// Fill in font attributes when not set
text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length), options: .longestEffectiveRangeNotRequired) { (value, subrange, _) in
guard value == nil, let font = font else { return }
text.addAttribute(.font, value: font, range: subrange)
}
return text
}()
}
}

Summary

We created a UILabel subclass which has a capability of opening tapped links. Useful, if we need just a label for displaying some text along with hyperlinks. Please take a look on the full implementation available here: HyperlinkLabel.

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.

Project

UILabelHyperlinks (Xcode 12.3)