Context Menu Previews

Episode #485 | 8 minutes | published on April 8, 2021 | Uses Xcode-12.4, Swift-5.3, iOS-14.4
Subscribers Only
Let's see how we can provide a custom view controller to preview when a context menu is opened. This is analogous to (and a replacement for) the Peek/Pop interaction for devices that supported 3D Touch.

Here I have created a view controller in the storyboard called a FontPreviewController. This is a simple table view controller that renders a test string at various font sizes.

It has no segues currently, but we can instantiate it with its storyboard identifier.

Now let's move over to FontListViewController and look at where we are currently showing the context menu:

override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        let font = fonts[indexPath.row]

        return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ -> UIMenu? in
           // ...         
       }
    }

Utilizing the Hidden Attribute

There's a quick thing I want to clean up before we continue. Previously we were showing a particular action based on the favorite status of the font.

let favoriteAction = UIAction(title: "Favorite", image: UIImage(systemName: "heart.fill"), identifier: nil,  state: .off) { _ in
    self.favorites.insert(font.familyName)
}

let unfavoriteAction = UIAction(title: "Unfavorite", image: UIImage(systemName: "heart.slash.fill"), identifier: nil, state: .on) { _ in
    self.favorites.remove(font.familyName)
}

let favAction: UIAction = self.favorites.contains(font.familyName) ? unfavoriteAction : favoriteAction

return UIMenu(title: font.familyName, image: nil, identifier: nil, options: [], children: [
                copyAction, favAction
])

This works, but there's a slightly cleaner way to do this. Each action has attributes that we can set. Let's change the two actions to have a .hidden attribute if the action should not apply:

let favoriteAction = UIAction(
    title: "Favorite",
    image: UIImage(systemName: "heart.fill"), 
    identifier: nil,
    attributes: isFavorite ? [.hidden] : [],
    state: .off) { _ in
    self.favorites.insert(font.familyName)
}

let unfavoriteAction = UIAction(
    title: "Unfavorite",
    image: UIImage(systemName: "heart.slash.fill"),
    identifier: nil,
    attributes: isFavorite ? [] : [.hidden],
    state: .on) { _ in
    self.favorites.remove(font.familyName)
}

Now we can add both of these actions, and the .hidden attribute will determine whether it is shown in the menu.

return UIMenu(title: font.familyName, image: nil,
    identifier: nil, options: [], 
    children: [
        copyAction, favoriteAction, unfavoriteAction
    ])

Providing a Preview

Ok now let's work on the preivew. Notice how we passed preview: nil in the UIContextMenuConfiguration call? We wil change this to a block that returns a view controller.

First let's define the block inline:

let preview: () -> UIViewController? = {
    let fontPreviewVC = self.storyboard?.instantiateViewController(
        withIdentifier: "FontPreviewController") as! FontPreviewController
    fontPreviewVC.fontFamily = font.familyName
    return fontPreviewVC
}

Now we can use this when defining the context menu:

return UIContextMenuConfiguration(
    identifier: nil,
    previewProvider: preview) { _ -> UIMenu? in
    // ...
}

Now when we tap and hold to open the context menu, we can see the view controller being used as a preview:

previewing the font at a few sizes

Handling the "Pop"

If the user taps on the preview, we can commit this preview by showing it modally (or pushing on a navigation stack).

We can override the method willPerformPreviewActionForMenuWith:animator:) to do this.

override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
    animator.addCompletion {
        if let preview = animator.previewViewController {
            self.show(preview, sender: self)
        }
    }
}

With this we can now pop the preview in place.

blog comments powered by Disqus