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

Render Grona components

This page places Grona’s own components in your screens. You add a slot at each placement once, ship the release, and from then on Grona’s configuration decides what fills it, for whom and against which control group. In UIKit the slot is GronaSlotView; in SwiftUI it is GronaSlot. If you want to draw the content with your own product views instead, see Host-rendered decisions.

A slot renders four component types: banner, progress, offer and message. For what each one looks like, see Grona components.

  1. Create a GronaSlotView with its placement and add it to your layout. Pin its leading, trailing and top edges, and give it no height constraint: the slot sets its own height.

  2. Set the slot’s screen when the screen loads. Setting it starts the slot.

    HomeViewController.swift
    import Grona
    import UIKit
    final class HomeViewController: UIViewController {
    private let homeTopSlot = GronaSlotView(placement: "home_top")
    private let stack = UIStackView()
    override func viewDidLoad() {
    super.viewDidLoad()
    stack.axis = .vertical
    stack.spacing = 12
    stack.translatesAutoresizingMaskIntoConstraints = false
    stack.addArrangedSubview(homeTopSlot)
    stack.setCustomSpacing(0, after: homeTopSlot) // no gap under an empty slot
    stack.addArrangedSubview(FeaturedRowView()) // your own content
    view.addSubview(stack)
    NSLayoutConstraint.activate([
    stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
    stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
    stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
    ])
    homeTopSlot.screen = .home
    }
    }
  3. Run the app. With nothing to show, the slot is laid out at zero height. When a campaign targets the placement, its component appears in the slot.

In a storyboard, set the view’s class to GronaSlotView and its module to Grona, set Placement in the Attributes inspector, then set screen from code. If you create the slot with init(frame:), set placement before screen, because setting screen is what binds the slot.

A slot with nothing to show stays visible at zero height, so the control arm is measured on the same view as the treatment arm. Grona logs anchor_visible for every arm once the slot’s top edge has been on screen for one second.

  • Never hide a slot: no isHidden = true, no alpha of 0, and in SwiftUI no .hidden() or .opacity(0). Do not wrap a GronaSlot in an if that removes it when you expect it to be empty. A hidden or removed slot is never measured, so only the treatment arm would be counted. In UIKit, debug builds log a warning once per slot that is hidden or fully transparent.
  • Give the slot no height constraint and no padding of its own. A height constraint or padding on the slot stops it collapsing to zero height when empty.
  • Watch stack spacing. UIStackView drops its spacing only next to hidden views, so a visible zero-height slot still gets the spacing on both sides. Use setCustomSpacing(0, after:) next to the slot, or VStack(spacing: 0) in SwiftUI, and put spacing inside your own content.

For why this matters, see Events and exposure.

The placement set is fixed. Use these names exactly.

Placement Where it goes
home_top Top of the home screen
home_feed In the home feed
plp_inline Inline in a product listing
pdp_below_buy On the product page, below the buy button
cart_line On a cart line. Keyed by the line’s product id, and the only placement for substitute decisions
cart_top Top of the cart
cart_below_lines In the cart, below the lines
checkout_above_pay On checkout, above the pay button
order_confirmation On the order confirmation screen
membership_top Top of the membership screen

A configuration that names any other placement is rejected for that campaign. For guidance on choosing placements, see Placements.

GronaScreen tells Grona where the shopper is. Rules read it through screen.type, screen.product, screen.category and screen.treatments.

Value Use it for
.home Home
.categories The category list
.search Search results
.cart The cart
.checkout Checkout
.category(_:) One category’s product listing, for example .category("dairy")
.product(_:) One product page, for example .product("sku_10432")
.orderConfirmation(treatmentTypes:) The confirmation screen. Pass the treatment types your order backend actually applied, not the ones the app showed
.other(_:) Any other screen, by your own name, for example .other("membership")

GronaScreen is Hashable. Two screens are equal when their type and values are equal, so .product("sku_10432") set twice is the same screen.

Each placement on a screen counts as one visit. Grona logs eligible, anchor_visible, exposure and served at most once per visit, which keeps counts honest when views are reused.

  • Setting screen starts a visit. Setting an equal value again continues it.
  • Setting screen to nil ends the visit and empties the slot. In SwiftUI, pass nil as the screen.
  • A slot that leaves its window and comes back, for example under a pushed screen or in another tab, keeps its visit however long it was away.
  • Another slot that binds the same placement, screen and key, such as one in a reused cell, joins the visit while a slot for it is in a window, or within 2 seconds after the last one left.
  • Switching a campaign off applies at once, including to content on screen. Every other configuration change, including a campaign switched back on, waits for the next visit.

A screen that stays alive, such as a tab’s root screen, keeps its visit, so a campaign that starts or is switched back on later appears there only after the visit ends. To show changes, end the visit when the screen reappears: set screen to nil and back, or cancel the decision’s subscription and request again. Each new visit logs eligible again.

To start a fresh visit, for example after Grona.refresh brings a campaign back, end the visit and bind again:

Grona.refresh { [weak self] in
guard let self else { return }
cartTopSlot.screen = nil // ends the visit
cartTopSlot.screen = .cart // starts a new one
}

A slot can sit in a cell, such as a promotional row in a product listing. Add it to the cell’s content view once, and set screen each time the cell is configured.

import Grona
import UIKit
final class PromoCell: UICollectionViewCell {
let slot = GronaSlotView(placement: "plp_inline")
override init(frame: CGRect) {
super.init(frame: frame)
slot.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(slot)
NSLayoutConstraint.activate([
slot.topAnchor.constraint(equalTo: contentView.topAnchor),
slot.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
slot.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
slot.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not used")
}
}
// When you configure the cell
cell.slot.screen = .category(categoryID)

Content often arrives after the cell is laid out. The slot then changes its height through Auto Layout, so let the list resize the cell: set selfSizingInvalidation to .enabledIncludingConstraints on the collection or table view, and use self-sizing cells.

collectionView.selfSizingInvalidation = .enabledIncludingConstraints

The visit is keyed on placement, screen and key, not on the view. Configuring a cell with the same placement and screen continues the visit while any slot for it is in a window, and for 2 seconds after the last one left. So a row that is reused across cells logs its events once. A row that comes back in a different cell more than 2 seconds after it left starts a new visit. A slot that leaves the window and comes back keeps its visit.

In SwiftUI, a GronaSlot in a List or LazyVStack row needs no extra setup: it reports its new height when content arrives, and the row resizes.

The SDK measures the slot’s top edge in its window. The top edge counts as visible when it lies inside the window and inside every ancestor view that clips its subviews, such as a scroll view, a table view or a collection view. It must stay visible for one continuous second while:

  • the app is in the foreground and active;
  • the slot’s window is the key window;
  • no view controller is presented over the slot’s screen: an alert, a sheet or a full-screen cover stops the second;
  • neither the slot nor any of its ancestors is hidden or fully transparent.

A zero-height slot counts when its top edge is inside those bounds, whatever its width.

  • A slot counts as live while it is in a window, neither it nor an ancestor is hidden, and the app is not in the background. An app that is only inactive, for example under a system sheet, still counts. A slot that has left the window, such as one under a pushed view controller or in an unselected tab, is not live.
  • Your workspace configuration caps the number of components on one screen (3 unless the configuration sets another number). Slots whose GronaScreen values are equal count as one screen. Lower-priority campaigns yield when the cap is reached. Only live slots count toward it.
  • Images come from your configuration as asset://img/<path> paths, which load from the img folder in your app’s main bundle, or as https URLs on hosts your configuration allows. Add bundled images in a folder named img as a folder reference, so Xcode keeps the folder in the bundle. Anything else is dropped and the component renders without it.

A banner’s call to action can add products to the cart or open a screen. The SDK runs it only from the shopper’s tap, validates it, and then calls your GronaHost. See Handle actions.