Skip to content
The mobile SDKs are available on request. Email support@grona.ai to get access.

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.

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.

  1. Add an anchor to the layout: the view the content will occupy. An empty vertical UIStackView has zero height and grows with its content. Keep it in the layout and visible.

  2. Call Grona.decision with 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 Grona
    import UIKit
    final class ProductViewController: UIViewController {
    private let productID: String
    private let contentStack = UIStackView()
    private let togetherAnchor = UIStackView()
    private var together: GronaSubscription?
    init(productID: String) {
    self.productID = productID
    super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) {
    fatalError("init(coder:) is not used")
    }
    override func viewDidLoad() {
    super.viewDidLoad()
    contentStack.axis = .vertical
    togetherAnchor.axis = .vertical
    contentStack.addArrangedSubview(ProductHeaderView(productID: productID)) // your own views
    contentStack.addArrangedSubview(togetherAnchor)
    // ... add contentStack to a scroll view
    together = Grona.decision(
    for: togetherAnchor,
    placement: "pdp_below_buy",
    screen: .product(productID)
    ) { [weak self] decision in
    guard let self else { return }
    if let decision, decision.type == .bundle {
    showTogether(decision)
    } else {
    clearTogether() // stays in the layout at zero height
    }
    }
    }
    }
  3. 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 view
    rail.titleLabel.text = decision.title ?? String(localized: "Frequently bought together")
    for product in decision.products {
    let cell = ProductCellView(productID: product.productID) // your own product cell
    cell.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() }
    }
    }

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.

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 TogetherRail does above.
  • Zero stack spacing around it. A UIStackView or SwiftUI stack adds its spacing next to a zero-height view. Use setCustomSpacing(0, after:) or spacing: 0 around the anchor.
  • SwiftUI: a container that always exists. Apply .gronaDecision to a concrete container, such as a VStack, that is there even when the decision is nil. Do not apply it to a Group, to EmptyView, or to a view inside an if. A modifier on a Group applies 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.

cart_line decisions are per line. Pass the line’s product id as the key:

// When you configure the cell for a cart 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)
}

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.

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 title, subtitle, badge, ctaLabel and product labels as plain text. In UIKit, set text, not attributedText built from HTML or Markdown, and turn off data detectors. In SwiftUI, pass the String to Text, which shows it verbatim. Never wrap it in LocalizedStringKey or AttributedString(markdown:).
  • Treat extras as 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.

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.

Grona.decision returns a GronaSubscription. You may ignore it.

  • The anchor keeps the subscription alive. Releasing the GronaSubscription does not cancel it, unlike Combine’s AnyCancellable. The subscription ends when you call cancel() or when the anchor is deallocated.
  • The anchor holds your closure. Capture self and 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 eligible and served once.
  • cancel() ends the visit. Cancel and request again when you want a fresh visit on the same screen, for example after Grona.refresh brings a campaign back. In SwiftUI, pass nil as 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.