Host-rendered decisions
This page renders Grona decisions with your app’s own views. A decision tells you which products to show at a placement, such as a frequently-bought-together rail under the buy button, and you draw them with the product cells you already have. Use this when the content must look like the rest of your catalogue. For banners, progress bars, offers and messages, use a slot instead.
Decision types
Section titled “Decision types”GronaDecision.type is a GronaDecisionType:
| Value | Raw value | Typical use |
|---|---|---|
.rail |
rail |
A row of products, for example in the home feed |
.bundle |
bundle |
Products to buy together, with an add-all button |
.productCard |
product_card |
One featured product |
.substitute |
substitute |
A replacement for an out-of-stock cart line, on cart_line only |
GronaDecisionType is a struct, not an enum, because newer configurations may add types that your app does not know how to draw. Treat any other value as no decision.
Request a decision
Section titled “Request a decision”-
Add an anchor to the layout: the view the content will occupy. An empty vertical
UIStackViewhas zero height and grows with its content. Keep it in the layout and visible. -
Call
Grona.decisionwith the anchor, the placement, the screen and a closure. It is a main-actor API, so call it from your view controller.ProductViewController.swift import Gronaimport UIKitfinal class ProductViewController: UIViewController {private let productID: Stringprivate let contentStack = UIStackView()private let togetherAnchor = UIStackView()private var together: GronaSubscription?init(productID: String) {self.productID = productIDsuper.init(nibName: nil, bundle: nil)}required init?(coder: NSCoder) {fatalError("init(coder:) is not used")}override func viewDidLoad() {super.viewDidLoad()contentStack.axis = .verticaltogetherAnchor.axis = .verticalcontentStack.addArrangedSubview(ProductHeaderView(productID: productID)) // your own viewscontentStack.addArrangedSubview(togetherAnchor)// ... add contentStack to a scroll viewtogether = Grona.decision(for: togetherAnchor,placement: "pdp_below_buy",screen: .product(productID)) { [weak self] decision inguard let self else { return }if let decision, decision.type == .bundle {showTogether(decision)} else {clearTogether() // stays in the layout at zero height}}}} -
Render the decision with your own views. Take names, prices and images from your catalogue, and wire your buttons to the decision’s actions:
extension ProductViewController {func showTogether(_ decision: GronaDecision) {clearTogether()let rail = TogetherRailView() // your own viewrail.titleLabel.text = decision.title ?? String(localized: "Frequently bought together")for product in decision.products {let cell = ProductCellView(productID: product.productID) // your own product cellcell.onAdd = { decision.add(product.productID) }rail.addProduct(cell)}rail.addAllButton.setTitle(decision.ctaLabel ?? String(localized: "Add all"), for: .normal)rail.onAddAll = { decision.addAll() }togetherAnchor.addArrangedSubview(rail)}func clearTogether() {togetherAnchor.arrangedSubviews.forEach { $0.removeFromSuperview() }}}
-
Add an anchor: a container that exists whether or not there is a decision, such as a
VStack(spacing: 0). It is empty, and so zero height, when there is no decision. -
Apply
.gronaDecisionto the anchor and keep the decision in state:ProductDetailView.swift import Gronaimport SwiftUIstruct ProductDetailView: View {let productID: String@State private var together: GronaDecision?var body: some View {ScrollView {VStack(spacing: 0) {ProductHeader(productID: productID) // your own viewsVStack(spacing: 0) {if let together, together.type == .bundle {TogetherRail(decision: together)}}.frame(maxWidth: .infinity).gronaDecision(placement: "pdp_below_buy", screen: .product(productID)) { decision intogether = decision}}}}} -
Render the decision with your own views, and wire your buttons to the decision’s actions:
TogetherRail.swift import Gronaimport SwiftUIstruct TogetherRail: View {let decision: GronaDecisionvar body: some View {VStack(alignment: .leading, spacing: 8) {Text(decision.title ?? String(localized: "Frequently bought together")).font(.headline)ScrollView(.horizontal) {HStack {ForEach(decision.products, id: \.productID) { product inProductCard(productID: product.productID) { // your own product carddecision.add(product.productID)}}}}Button(decision.ctaLabel ?? String(localized: "Add all")) {decision.addAll()}}.padding(.vertical, 12) // spacing inside the content, not on the anchor}}
The closure runs on the main actor, never synchronously inside the call that subscribes, and runs again whenever the decision changes. An unchanged decision is not delivered again. nil means show nothing.
Shape the anchor
Section titled “Shape the anchor”The anchor is measured the same way in every arm, so its shape matters as much as its content.
- Zero height when empty. Put no padding, minimum height or height constraint on the anchor. Put spacing inside the content you draw, as
TogetherRaildoes above. - Zero stack spacing around it. A
UIStackViewor SwiftUI stack adds its spacing next to a zero-height view. UsesetCustomSpacing(0, after:)orspacing: 0around the anchor. - SwiftUI: a container that always exists. Apply
.gronaDecisionto a concrete container, such as aVStack, that is there even when the decision isnil. Do not apply it to aGroup, toEmptyView, or to a view inside anif. A modifier on aGroupapplies to the group’s children, and an empty group has none, so the anchor would disappear with the content.
The SDK measures a SwiftUI anchor through a UIKit view it places behind the modified view, with the same frame. So it sees the window and the UIKit views that clip, including the scroll views behind ScrollView and List. It does not see clipping that exists only in SwiftUI, such as .clipped() or .mask. For the full rule, see How visibility is measured.
Keyed placements
Section titled “Keyed placements”cart_line decisions are per line. Pass the line’s product id as the key:
// When you configure the cell for a cart lineGrona.decision(for: cell.substituteAnchor, placement: "cart_line", screen: .cart, key: line.productID) { [weak cell] decision in cell?.showSubstitute(decision?.type == .substitute ? decision : nil)}import Gronaimport SwiftUI
struct CartLineRow: View { let line: AcmeCartLine @State private var substitute: GronaDecision?
var body: some View { VStack(alignment: .leading, spacing: 0) { CartLineContent(line: line) // your own row VStack(spacing: 0) { if let substitute, substitute.type == .substitute { SubstituteOffer(decision: substitute) // calls substitute.replace(with:) } } .frame(maxWidth: .infinity) .gronaDecision(placement: "cart_line", screen: .cart, key: line.productID) { decision in substitute = decision } } }}A substitute shows only while its line is in the basket and out of stock, so push the basket with inStock: false for that line. Call decision.replace(with:) from the shopper’s tap to swap the line.
The GronaDecision value
Section titled “The GronaDecision value”GronaDecision is a Sendable struct. Two values are equal only when they come from the same delivery. A decision delivered again, for example after a rebind, is a new value even when its content is the same, so SwiftUI redraws with its new action handle. Do not compare decisions field by field to skip updates.
| Property | Type | Meaning |
|---|---|---|
placement |
String |
The placement it was requested for. |
campaignID |
String |
The campaign it came from. |
variantID |
String |
The variant within that campaign. |
type |
GronaDecisionType |
.rail, .bundle, .productCard or .substitute, or a type from a newer configuration. |
title, subtitle, badge, ctaLabel |
String? |
Copy from the configuration. Each may be nil. |
products |
[GronaDecisionProduct] |
The products to show, each with productID, quantity and an optional label. Products your app does not know or has out of stock are already removed. |
key |
String? |
For keyed placements such as cart_line, the line this decision belongs to. |
extras |
[String: String] |
Layout hints from the configuration, for example position for feed rows. |
isPreview |
Bool |
true while a preview pin produced this decision. You may badge it. |
source |
GronaSource |
Placement, campaign and variant, as passed to GronaHost. |
A decision with no products left is not delivered, so products is never empty.
Render it safely
Section titled “Render it safely”- Render
title,subtitle,badge,ctaLabeland product labels as plain text. In UIKit, settext, notattributedTextbuilt from HTML or Markdown, and turn off data detectors. In SwiftUI, pass theStringtoText, which shows it verbatim. Never wrap it inLocalizedStringKeyorAttributedString(markdown:). - Treat
extrasas untrusted hints. Check each value against what your layout accepts and ignore anything else. - Take product names, prices and images from your own catalogue, never from the decision.
Actions
Section titled “Actions”Call these from the shopper’s own tap, on the main actor. add, addAll and replace check the request, log the action, call your GronaHost and return a GronaAddResult, which you may ignore. dismiss logs the dismissal and returns nothing.
| Method | What it does |
|---|---|
add(_:quantity:) |
Adds a product in the decision through GronaHost.add. quantity defaults to 1. |
addAll() |
Adds every product in the decision at its quantity. |
replace(with:) |
For substitutes: replaces the key line with the given product through GronaHost.replace, at the line’s current quantity (capped at maxAddQuantity). |
dismiss() |
Hides this decision for the rest of the session. |
Before it calls your host, the SDK drops lines whose product is not in the decision, whose quantity is outside 1 to maxAddQuantity, that repeat a product, or that your GronaHost.product(id:) does not know or reports out of stock. It keeps at most maxAddLines lines. If nothing is left, the call returns .rejected without calling your host, and the SDK logs the action with its rejected result. If the decision is stale, because its subscription has ended or now shows another campaign or variant, the call returns .rejected and logs nothing.
When an action returns .rejected, the shopper still asked for the product. You may fall back to a plain add through your own cart code.
Subscriptions and rebinding
Section titled “Subscriptions and rebinding”Grona.decision returns a GronaSubscription. You may ignore it.
- The anchor keeps the subscription alive. Releasing the
GronaSubscriptiondoes not cancel it, unlike Combine’sAnyCancellable. The subscription ends when you callcancel()or when the anchor is deallocated. - The anchor holds your closure. Capture
selfand cells weakly, as the examples do, or they are never released. - Binding the same anchor again replaces the previous subscription. Cells can rebind each time they are configured. With the same placement, screen and key, the visit continues and the new closure receives the current decision.
- An anchor keeps its visit while its subscription lives, also when it leaves its window and comes back, for example under a pushed view controller.
- The same placement, screen and key on another anchor continues the visit while any anchor for it is in a window, and for 2 seconds after. So reused cells log
eligibleandservedonce. cancel()ends the visit. Cancel and request again when you want a fresh visit on the same screen, for example afterGrona.refreshbrings a campaign back. In SwiftUI, passnilas the screen for one update instead, as Screen visits shows for slots.
extension CartViewController { // Called each time a cell is configured for a cart line func configure(_ cell: CartLineCell, with line: AcmeCartLine) { cell.bind(line) Grona.decision(for: cell.substituteAnchor, placement: "cart_line", screen: .cart, key: line.productID) { [weak cell] decision in cell?.showSubstitute(decision?.type == .substitute ? decision : nil) } }}The SwiftUI modifier subscribes when the modified view is created and keeps the subscription, and its visit, while that view exists, including while it is off screen under a pushed view. It calls the latest closure you pass and resubscribes when the placement, screen or key changes. A nil screen ends the subscription.
Next steps
Section titled “Next steps”- Handle actions in your
GronaHost. - Read Patterns for host-rendered decisions.