-
Notifications
You must be signed in to change notification settings - Fork 141
ShopPage Component example #220
base: master
Are you sure you want to change the base?
Changes from 12 commits
ce728bd
b46e782
37f35a1
f0dd2b9
bb66313
b86ec48
07e8593
8b62a91
0437194
6e2edfd
c526ae5
2908bed
f00e5ae
ce99d19
5a833fb
f889eff
5786521
0f908e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,396 @@ | ||
# Shop page | ||
_Goal: Create a page to sell developer products in a game._ | ||
|
||
[add an image of the result we are expecting] | ||
|
||
## Top Component | ||
Let's start by creating the top level component, that will be the page containing the items for sale in the game. When creating a new component, we need to think about what will this component manage itself and what will be given to that component from the parent component. | ||
|
||
In our case, the ShopPage component we're making will receive it's products from a parent component: that means that the product items will be received as props in our component. | ||
|
||
> Props or state? | ||
> | ||
> This is a question that often comes to our mind when we start working Roact. Remember that state is internal to the component. Things that are relevant only to a component should go in the state. | ||
> | ||
> On the other hand, props are used to pass data from parent components to child components. It implies that both parent component and it's child are aware of the structure of the props dictionary and depends on it. | ||
|
||
For our first draft of the component, we will define some props to be able to pass parameters to customize our shop page. We will start by adding the following: | ||
- **frameProps:** a dictionary of props that will be passed to the scrolling frame to change it's visuals | ||
- **items:** a list of table that represents our products we want to sell | ||
- **padding:** the number of pixels between each of our items in the shop-page | ||
|
||
```lua | ||
local ShopPage = Roact.Component:extend("ShopPage") | ||
|
||
function ShopPage:render() | ||
local props = self.props | ||
|
||
local items = props.items | ||
local frameProps = props.frameProps | ||
local padding = props.padding | ||
|
||
return Roact.createElement("ScrollingFrame", frameProps, { | ||
UIGrid = Roact.createElement("UIGridLayout", { | ||
CellPadding = UDim2.new(0, padding, 0, padding), | ||
CellSize = UDim2.new(0, 100, 0, 100), | ||
HorizontalAlignment = Enum.HorizontalAlignment.Center, | ||
VerticalAlignment = Enum.VerticalAlignment.Center, | ||
SortOrder = Enum.SortOrder.LayoutOrder, | ||
}), | ||
}) | ||
end | ||
|
||
return ShopPage | ||
``` | ||
|
||
As you can see, the `frameProps` are passed to the ScrollingFrame creation! That way, we can control how the container look from outside the parent of the component. | ||
|
||
### Viewing the component | ||
|
||
Now that we have started working on our first component, we want to be able to see the results. To do that, let's create a LocalScript that will put out component in context. This script needs to _mount_ our ShopPage into a parent that will simply be a ScreenGui. | ||
|
||
```lua | ||
local ReplicatedStorage = game:GetService("ReplicatedStorage") | ||
local Players = game:GetService("Players") | ||
|
||
local Components = ReplicatedStorage:WaitForChild("Components") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not mentioned beforehand that you should store ShopPage in ReplicatedStorage.Components. Would be good to clear that up. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a comment above to try to make it clearer, if you think of anything else we could do let me know! Or you can submit a suggestion too! |
||
|
||
local Roact = require(ReplicatedStorage.Roact) | ||
|
||
local ShopPage = require(Components:WaitForChild("ShopPage")) | ||
|
||
local screenGui = Instance.new("ScreenGui") | ||
screenGui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") | ||
|
||
local shopPage = Roact.createElement(ShopPage, { | ||
items = {}, -- we're not ready for this yet so it's just an empty list | ||
frameProps = { | ||
-- here we define the properties of our ShopPage scrolling frame | ||
AnchorPoint = Vector2.new(0.5, 0.5), | ||
BorderSizePixel = 0, | ||
Position = UDim2.new(0.5, 0, 0.5, 0), | ||
Size = UDim2.new(0.5, 0, 0.5, 0), | ||
}, | ||
padding = 10, | ||
}) | ||
|
||
Roact.mount(shopPage, screenGui) | ||
``` | ||
|
||
The two important things to notice here are: | ||
- an element is created from our ShopPage component, where we specified the different props we want | ||
- `Roact.mount` will create the real UI objects we need to see the results | ||
|
||
### Adjusting Canvas Size from Items | ||
|
||
We want to adjust the scrolling frame so its *CanvasSize* property always fit the height of the items. For that, we will add new props to control the sizing of the grid elements. | ||
- **itemsPerRow:** the number of items on each row | ||
- **itemAspectRatio:** the aspect ratio of each item | ||
|
||
With these new props, we are going to be able to compute the optimal cell size for our grid and also the height of the scrolling frame *CanvasSize*. Once the *AbsoluteSize* property of our ScrollingFrame changes, we will update the state of our component with the new computed cell size and height. Let's add these computed values to the component state. | ||
|
||
```lua | ||
function ShopPage:init() | ||
-- set default values | ||
self:setState({ | ||
cellSize = 100, | ||
canvasHeight = 100, | ||
}) | ||
end | ||
``` | ||
|
||
And update the render method to use the state. | ||
|
||
```lua | ||
function ShopPage:render() | ||
local props = self.props | ||
|
||
local items = props.items | ||
local frameProps = props.frameProps | ||
local padding = props.padding | ||
|
||
local cellSize = state.cellSize | ||
local canvasHeight = state.canvasHeight | ||
|
||
-- we need to clone the frameProps because we can't mutate it! | ||
local newFrameProps = {} | ||
|
||
for key, value in pairs(frameProps) do | ||
newFrameProps[key] = value | ||
end | ||
|
||
-- now that we have cloned the scrolling frame props, we add new entries | ||
newFrameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) | ||
|
||
return Roact.createElement("ScrollingFrame", newFrameProps, { | ||
UIGrid = Roact.createElement("UIGridLayout", { | ||
CellPadding = UDim2.new(0, padding, 0, padding), | ||
-- now we can use the cellSize from the state! | ||
CellSize = UDim2.new(0, cellSize, 0, cellSize * itemAspectRatio), | ||
HorizontalAlignment = Enum.HorizontalAlignment.Center, | ||
VerticalAlignment = Enum.VerticalAlignment.Center, | ||
SortOrder = Enum.SortOrder.LayoutOrder, | ||
}), | ||
}) | ||
end | ||
``` | ||
|
||
Then, we need to update the state when the scrolling frame size changes. To connect a function to a changed property event, we use [`Roact.Change.NameOfProperty`](https://roblox.github.io/roact/api-reference/#roactchange). In our case, we will listen to the `AbsoluteSize` property change of the scrolling frame. The cell size of the grid will be computed to maximize the horizontal space. After, the aspect ratio of the items is used to find the vertical space needed to contain all the items. | ||
|
||
```lua | ||
function ShopPage:init() | ||
... | ||
|
||
self.onAbsoluteSizeChanged = function(frame) | ||
local props = self.props | ||
local padding = props.padding | ||
local itemsPerRow = props.itemsPerRow | ||
|
||
local totalWidth = frame.AbsoluteSize.X | ||
local cellWidth = (totalWidth - padding * (itemsPerRow + 1)) / itemsPerRow | ||
local cellHeight = cellWidth * props.itemAspectRatio | ||
local rows = math.ceil(#props.items / itemsPerRow) | ||
local canvasHeight = rows * cellHeight + padding * (rows + 1) | ||
|
||
-- we update the state with the new values | ||
self:setState({ | ||
cellSize = cellWidth, | ||
canvasHeight = canvasHeight, | ||
}) | ||
end | ||
end | ||
|
||
function ShopPage:render() | ||
... | ||
-- connect the function in the render method | ||
newFrameProps[Roact.Change.AbsoluteSize] = self.onAbsoluteSizeChanged | ||
... | ||
end | ||
``` | ||
|
||
## Adding Items | ||
|
||
In the previous section we created a scrolling frame that contains a UIGridLayout object. Now, we want to add elements that represent each item we sell in our shop. In order to be able to add a variable amount of items in our component, we will iterate on the list of products to create an element for each of them. | ||
|
||
To keep our component simple, let's divide this feature into different components: | ||
- **ProductItem:** a card that shows the price of an item with its image and when clicked prompt the player to buy the product | ||
- **ProductItemList:** iterate on the list of items to create each individual ProductItem element | ||
jeparlefrancais marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
### ProductItemList | ||
|
||
This component will simply be given the product list and create all the ProductItem elements. Since it does not need any internal state, a functional component will do the job. We have not done our ProductItem component yet, but we will suppose it is there for now. | ||
|
||
We will define the props that the ProductItem will use to render it's component. We will make the ProductItem component accept these props: | ||
**image:** the image representing the product that will be shown by the component. | ||
**price:** how much robux the product costs. | ||
**productId:** the identifier of the developer product. | ||
**order:** use to specify the order of the items in the shop. We will make this optional, so if it is not provided, it will sort the items by their costs. | ||
|
||
```lua | ||
local ReplicatedStorage = game:GetService("ReplicatedStorage") | ||
|
||
local Components = ReplicatedStorage:WaitForChild("Components") | ||
|
||
local Roact = require(ReplicatedStorage.Roact) | ||
|
||
local ProductItem = require(Components:WaitForChild("ProductItem")) | ||
|
||
local function ProductItemList(props) | ||
local items = props.items | ||
|
||
local elements = {} | ||
|
||
for i=1, #items do | ||
local item = items[i] | ||
|
||
elements[item.identifier] = Roact.createElement(ProductItem, { | ||
image = item.image, | ||
price = item.price, | ||
productId = item.productId, | ||
order = item.order, | ||
}) | ||
end | ||
|
||
return Roact.createFragment(elements) | ||
end | ||
|
||
return ProductItemList | ||
``` | ||
|
||
With that, we can go change our render function in the ShopPage component to create an element of ProductItemList and pass the items list as a prop. | ||
|
||
```lua | ||
return Roact.createElement("ScrollingFrame", frameProps, { | ||
UIGrid = Roact.createElement("UIGridLayout", { | ||
CellPadding = UDim2.new(0, padding, 0, padding), | ||
CellSize = UDim2.new(0, 100, 0, 100), | ||
HorizontalAlignment = Enum.HorizontalAlignment.Center, | ||
VerticalAlignment = Enum.VerticalAlignment.Center, | ||
SortOrder = Enum.SortOrder.LayoutOrder, | ||
}), | ||
ProductItems = Roact.createElement(ProductItemList, { | ||
items = items | ||
}), | ||
}) | ||
``` | ||
|
||
> **Fragments** | ||
> | ||
> In case you have forgotten about fragments: elements contained in a fragment are parented to the fragment's parent. That way, we can split the responsability of the grid view from the content. The ShopPage component will handle the grid view, so it owns the *UIGridLayout* object, and it will give the responsability to the *ProductItemList* component to take care of the content. | ||
|
||
## Product Item | ||
For our last component, we are going to use a stateful component to be able to use some lifecycle methods. Let's start first by doing a basic view of our props. | ||
|
||
```lua | ||
local PADDING = 20 | ||
|
||
local ProductItem = Roact.Component:extend("ProductItem") | ||
|
||
function ProductItem:render() | ||
local props = self.props | ||
|
||
local image = props.image | ||
local price = props.price | ||
local order = props.order or price | ||
|
||
return Roact.createElement("ImageButton", { | ||
BackgroundTransparency = 1, | ||
Image = "", | ||
LayoutOrder = order, | ||
}, { | ||
Icon = Roact.createElement("ImageLabel", { | ||
AnchorPoint = Vector2.new(0.5, 0.5), | ||
BackgroundTransparency = 1, | ||
Image = image, | ||
Position = UDim2.new(0.5, 0, 0.5, 0), | ||
Size = UDim2.new(1, -PADDING, 1, -PADDING), | ||
}), | ||
PriceLabel = Roact.createElement("TextLabel", { | ||
AnchorPoint = Vector2.new(0.5, 1), | ||
BackgroundTransparency = 1, | ||
Font = Enum.Font.SourceSans, | ||
Text = ("R$ %d"):format(price), | ||
TextColor3 = Color3.fromRGB(10, 200, 10), | ||
TextScaled = true, | ||
TextStrokeTransparency = 0, | ||
TextStrokeColor3 = Color3.fromRGB(255, 255, 255), | ||
Position = UDim2.new(0.5, 0, 1, 0), | ||
Size = UDim2.new(1, 0, 0.3, 0), | ||
}), | ||
}) | ||
end | ||
|
||
return ProductItem | ||
``` | ||
|
||
We can now populate our shop with an item! Let's go back to where we mount our ShopPage component and add an item to our list. | ||
|
||
```lua | ||
local shopPage = Roact.createElement(ShopPage, { | ||
items = { | ||
{ | ||
identifier = "Red Visor", | ||
image = "https://www.roblox.com/asset-thumbnail/image?assetId=139618072&width=420&height=420&format=png", | ||
price = 30, | ||
productId = 0, | ||
}, | ||
}, | ||
frameProps = { | ||
-- here we define the properties of our ShopPage scrolling frame | ||
AnchorPoint = Vector2.new(0.5, 0.5), | ||
BorderSizePixel = 0, | ||
Position = UDim2.new(0.5, 0, 0.5, 0), | ||
Size = UDim2.new(0.5, 0, 0.5, 0), | ||
}, | ||
padding = 10, | ||
}) | ||
``` | ||
|
||
Now, we want to prompt the player to buy the product when he clicks on the button. For that, we will use the *Activated* event to fire a function. We can create this function in the [`init`](https://roblox.github.io/roact/guide/state-and-lifecycle/#lifecycle-methods) lifecycle method. | ||
|
||
```lua | ||
local MarketplaceService = game:GetService("MarketplaceService") | ||
|
||
function ProductItem:init() | ||
self.onActivated = function() | ||
local props = self.props | ||
|
||
MarketplaceService:PromptProductPurchase(Players.LocalPlayer, props.productId) | ||
end | ||
end | ||
``` | ||
|
||
Then, we simply use `Roact.Event.Activated` to tell Roact to connect the function to the _Activated_ event. | ||
|
||
```lua | ||
return Roact.createElement("ImageButton", { | ||
BackgroundTransparency = 1, | ||
Image = "", | ||
LayoutOrder = order, | ||
[Roact.Event.Activated] = self.onActivated, | ||
}, { | ||
... | ||
} | ||
``` | ||
> **Why is there brackets `[]` here?** | ||
> | ||
> In lua, the keys in a table can be any value. The most common way to map keys and values in a table construction is with `foo = something`. In this case, `foo` will be considered a string. So if we had this entry in a table `t`, we can access its value with `t.foo` or `t["foo"]`. | ||
> | ||
> What if we want a key like `"foo.bar"`? For this, the lua syntax allow us to write this entry as `["foo.bar"] = something`. So between the brackets, any value can be given. That is why brackets are use for `Roact.Event.Activated`: we don't want to pass a string that could collide with other props, instead we use a unique value owned by Roact. | ||
|
||
## Adding Animations | ||
|
||
To make our UI more lively, we are going to use the [*TweenService*](https://developer.roblox.com/en-us/api-reference/class/TweenService) to create a new Tween object. The goal is to make the icon bigger when the mouse is inside the button. To be able to do that, we need a reference to the roblox instance we want to animate. Roact can provide that through refs. First we start by creating the ref in the [`init`](https://roblox.github.io/roact/guide/state-and-lifecycle/#lifecycle-methods) lifecycle method. | ||
|
||
```lua | ||
function ProductItem:init() | ||
self.ref = Roact.createRef() | ||
... | ||
end | ||
``` | ||
|
||
Since we want to animate the size of the icon, the ref is going to be in the ImageLabel props. | ||
|
||
```lua | ||
Icon = Roact.createElement("ImageLabel", { | ||
AnchorPoint = Vector2.new(0.5, 0.5), | ||
BackgroundTransparency = 1, | ||
Image = image, | ||
Position = UDim2.new(0.5, 0, 0.5, 0), | ||
Size = UDim2.new(1, -PADDING, 1, -PADDING), | ||
[Roact.Ref] = self.ref, | ||
}), | ||
``` | ||
|
||
Next step is to create the Tween object when the component is mounted. The [`didMount`](https://roblox.github.io/roact/guide/state-and-lifecycle/#lifecycle-methods) lifecycle method is perfectly suited for this case, since it will be called once the instance object is created. | ||
|
||
```lua | ||
function ProductItem:didMount() | ||
local tweenInfo = TweenInfo.new(0.2) | ||
local icon = self.ref:getValue() | ||
|
||
self.toBigIcon = TweenService:Create(icon, tweenInfo, { | ||
Size = UDim2.new(1, 0, 1, 0), | ||
}) | ||
self.toNormalIcon = TweenService:Create(icon, tweenInfo, { | ||
Size = UDim2.new(1, -PADDING, 1, -PADDING), | ||
}) | ||
end | ||
``` | ||
|
||
Don't forget to clean up! When the component will be unmounted, we need to destroy the Tween objects that were created. We use the [`willUnmount`](https://roblox.github.io/roact/guide/state-and-lifecycle/#lifecycle-methods) lifecycle method to destroy the objects. | ||
|
||
```lua | ||
function ProductItem:willUnmount() | ||
self.toBigIcon:Destroy() | ||
self.toNormalIcon:Destroy() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do Tweens actually have to be destroyed? Given that they're only stored in memory they should be cleaned up regardless when ProductItem is unmounted. They also don't seem like they have a Destroy method There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think you can find the info on the devhub. It does not show the ancestor functions and I don't really understand how I am suppose to know what are the ancestors of the class I am looking at. In fact, it's not really clear if it's an Instance Anyway, you can check the ancestors on rodocs and yes apparently it's a descendant of Instance. But should we still destroy it? I wanted to have an example of clean up you can do in the |
||
end | ||
``` | ||
|
||
## Integration | ||
|
||
### In a Roact project | ||
Here explain how the component can be used my another component or how it can be mounted on it's own (like main.client.lua does) | ||
|
||
### In a non-Roact project | ||
Explain how the page could be mounted in an existing UI instances tree. That opens the subject that it is possible to slowly port an existing UI to use Roact part by part. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could frameProps be removed? It seems strange to include as changing the style of the ScrollingFrame isn't part of what we want to be teaching. It seemed like you were modifying frameProps to change the CanvasSize but could that be handled differently without bloating the tutorial?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My goal was to make the frame style accessible from the top level component, so someone could easily modify it from the script used to mount the component to fit his style
Having it also allow to tell the reader that the props cannot be mutated in the ShopPage component.
How would you change it?