iOS Action Sheet with Strada
Virtualtrails uses Strada to bridge native iOS code to the web application, including notifications, native Apple + Google Signin and Apple Healthkit integration. I added a feature yesterday to replace a Bootstrap dropdown with a native iOS sheet, just because it’s a more intuitive experience for users.
Strada has two parts - the web side (which is in the form of a Stimulus controller with some guards built in so it only runs when Strada’s native side is available), and the iOS side (which is in the form of a ‘component’ which receives ‘messages’ from the web side, and can reply to them).
The Strada Bridge Component is pretty straightforward:
import { BridgeComponent, Message } from '@hotwired/strada';
import { Turbo } from '@hotwired/turbo-rails';
interface PresentDropdownNavigationSheetLink {
text: string;
url: string;
style: string;
}
interface PresentDropdownNavigationSheetData {
selectedLink?: PresentDropdownNavigationSheetLink;
links: PresentDropdownNavigationSheetLink[];
}
interface PresentDropdownNavigationSheetMessage extends Message {
data: PresentDropdownNavigationSheetData;
}
export default class DropdownNavigationSheetController extends BridgeComponent {
public static component = 'dropdown-navigation-sheet';
public static values = { links: Array };
private readonly linksValue: PresentDropdownNavigationSheetData[] | undefined;
public present(e: Event): boolean {
e.stopImmediatePropagation();
this.send(
'present',
{ links: this.linksValue },
({ data }: PresentDropdownNavigationSheetMessage) => {
if (data.selectedLink) {
Turbo.visit(data.selectedLink.url);
}
}
);
return false;
}
}
When the ‘present’ action is triggered from the element, it sends a ‘present’ message to the iOS component, with the links to present in the action sheet. Each link has text, a URL (most of the time a path in my case), and a style (which maps to the iOS action sheet item styles). iOS will respond with a message when the user selects an action, with the selectedLink property filled in. I then use Turbo to ‘visit’ this URL - this is essentially window.location, but with extra powers around other native integration (e.g. open a URL modally).
The iOS code is super simple, it just responds to ‘present’ messages by opening an action sheet, and replying with the selected item:
import SwiftUI
import Strada
final class DropdownNavigationSheetComponent: BridgeComponent {
override class var name: String { "dropdown-navigation-sheet" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else {
return
}
switch event {
case .present:
handlePresentEvent(message: message)
}
}
private var viewController: UIViewController? {
delegate.destination as? UIViewController
}
private func handlePresentEvent(message: Message) {
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
guard var data: PresentMessageData = message.data() else { return }
let links = data.links
for (index, link) in links.enumerated() {
let action = UIAlertAction(title: link.text, style: link.actionStyle()) { [weak self] _ in
let newData = PresentMessageData(links: links, selectedLink: links[index])
self!.reply(with: message.replacing(data: newData))
}
alertController.addAction(action)
}
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
viewController!.present(alertController, animated: true, completion: nil)
}
}
private extension DropdownNavigationSheetComponent {
enum Event: String {
case present
}
struct DropdownNavigationSheetLink: Encodable, Decodable {
let url: String?
let text: String
var style: String = "default"
func actionStyle() -> UIAlertAction.Style {
switch self.style {
case "destructive":
.destructive
case "cancel":
.cancel
default:
.default
}
}
}
struct PresentMessageData: Encodable, Decodable {
let links: [DropdownNavigationSheetLink]
let selectedLink: DropdownNavigationSheetLink?
}
}
I use some ! assertions in here to make sure things fall over when properties I expect to be defined are not. Honestly I could probably change these to be ? and it can just do nothing.
The markup that invokes this is also pretty simple. I build the links collection in ERB which is pretty gross, but also contained to this menu partial, and I can quite easily extract it to an object. This is one of the times I kind of want to use viewcomponents, but I also then would be adding a dependency for this one thing. The reason I pass the links as a value is just to not encode anything in the Stimulus controller, since I’ll use different links based on user access and permissions, and in different places.
<% links = [
policy(progress_update).show? ? { text: "Share", url: share_path, style: "default" } : nil,
policy(progress_update).edit? ? { text: "Edit", url: edit_path, style: "default" } : nil
].compact %>
<% button_attrs.merge!(data: {
controller: "bridge--dropdown-navigation-sheet",
action: "bridge--dropdown-navigation-sheet#present",
bridge__dropdown_navigation_sheet_links_value: links
}) %>
# button_attrs then gets used to make a button, e.g.
button_tag("More options", **button_attrs)