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.
Add a slot
Section titled “Add a slot”-
Create a
GronaSlotViewwith 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. -
Set the slot’s
screenwhen the screen loads. Setting it starts the slot.HomeViewController.swift import Gronaimport UIKitfinal class HomeViewController: UIViewController {private let homeTopSlot = GronaSlotView(placement: "home_top")private let stack = UIStackView()override func viewDidLoad() {super.viewDidLoad()stack.axis = .verticalstack.spacing = 12stack.translatesAutoresizingMaskIntoConstraints = falsestack.addArrangedSubview(homeTopSlot)stack.setCustomSpacing(0, after: homeTopSlot) // no gap under an empty slotstack.addArrangedSubview(FeaturedRowView()) // your own contentview.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}} -
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.
-
Add a
GronaSlotwith its placement and the current screen. It starts when it appears.HomeView.swift import Gronaimport SwiftUIstruct HomeView: View {var body: some View {ScrollView {VStack(spacing: 0) {GronaSlot(placement: "home_top", screen: .home)FeaturedRow() // your own content}.padding(.horizontal, 16)}}} -
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, and the slot grows to fit it.
GronaSlot wraps a GronaSlotView, so everything on this page applies to both.
Keep the slot visible
Section titled “Keep the slot visible”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, noalphaof 0, and in SwiftUI no.hidden()or.opacity(0). Do not wrap aGronaSlotin anifthat 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.
UIStackViewdrops its spacing only next to hidden views, so a visible zero-height slot still gets the spacing on both sides. UsesetCustomSpacing(0, after:)next to the slot, orVStack(spacing: 0)in SwiftUI, and put spacing inside your own content.
For why this matters, see Events and exposure.
Placements
Section titled “Placements”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.
Screens
Section titled “Screens”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.
Screen visits
Section titled “Screen visits”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
screenstarts a visit. Setting an equal value again continues it. - Setting
screentonilends the visit and empties the slot. In SwiftUI, passnilas 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}SwiftUI merges state changes made in the same update, so the nil must reach the slot in an update of its own. Set it to nil, then set the screen again from .task(id:), which runs after the update:
import Gronaimport SwiftUI
struct CartView: View { @State private var screen: GronaScreen? = .cart
var body: some View { ScrollView { VStack(spacing: 0) { GronaSlot(placement: "cart_top", screen: screen) CartLinesView() // your own content } } .refreshable { await Grona.refresh() screen = nil // ends the visit } .task(id: screen) { if screen == nil { screen = .cart } // starts a new one } }}Slots in table and collection views
Section titled “Slots in table and collection views”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 Gronaimport 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 cellcell.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 = .enabledIncludingConstraintsThe 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.
How visibility is measured
Section titled “How visibility is measured”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.
Visibility and limits
Section titled “Visibility and limits”- 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
GronaScreenvalues 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 theimgfolder in your app’s main bundle, or ashttpsURLs on hosts your configuration allows. Add bundled images in a folder namedimgas a folder reference, so Xcode keeps the folder in the bundle. Anything else is dropped and the component renders without it.
Taps and actions
Section titled “Taps and actions”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.
Next steps
Section titled “Next steps”- Handle actions from Grona components.
- Match components to your app.
- Render decisions with your own views.