From ce728bde35fa4d5b7afc4179551bd74bb18c0eac Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Thu, 20 Jun 2019 16:46:13 -0700 Subject: [PATCH 01/16] Initial example code and tutorial general lines --- docs/tutorials/shop-page.md | 31 ++++++++ examples/shop-page/Components/ProductItem.lua | 69 +++++++++++++++++ .../shop-page/Components/ProductItemList.lua | 35 +++++++++ examples/shop-page/Components/ShopPage.lua | 77 +++++++++++++++++++ examples/shop-page/default.project.json | 18 +++++ examples/shop-page/main.client.lua | 40 ++++++++++ 6 files changed, 270 insertions(+) create mode 100644 docs/tutorials/shop-page.md create mode 100644 examples/shop-page/Components/ProductItem.lua create mode 100644 examples/shop-page/Components/ProductItemList.lua create mode 100644 examples/shop-page/Components/ShopPage.lua create mode 100644 examples/shop-page/default.project.json create mode 100644 examples/shop-page/main.client.lua diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md new file mode 100644 index 00000000..ad07a544 --- /dev/null +++ b/docs/tutorials/shop-page.md @@ -0,0 +1,31 @@ +# 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. + +```lua + +``` + +### Viewing the component + + +## Items Grid + + +## Product Item +[remind state and props] + +### Adding some animations + + +## 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. diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua new file mode 100644 index 00000000..7369b628 --- /dev/null +++ b/examples/shop-page/Components/ProductItem.lua @@ -0,0 +1,69 @@ +local MarketplaceService = game:GetService("MarketplaceService") +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Roact = require(ReplicatedStorage.Roact) + +local ProductItem = Roact.Component:extend("ProductItem") + +--[[ + Props: { + image: the image displayed + price: how much robux the product cost + productId: the developer product id + + -- Optional props + order: the index at which the item should be shown. If not + provided, the price will be used as the index. + } +]] + +function ProductItem:init() + self:setState({ + onMouseEnter = function() + -- grow the icon a little + end, + onMouseLeave = function() + -- go back to original size + end, + onActivated = function() + local props = self.props + + MarketplaceService:PromptProductPurchase(Players.LocalPlayer, props.productId) + end + }) + self.padding, self.updatePadding = Roact.createBinding(0) +end + +function ProductItem:render() + local props = self.props + local state = self.state + local padding = self.padding + + return Roact.createElement("ImageButton", { + BackgroundTransparency = 1, + Image = '', + LayoutOrder = props.order or props.price, + [Roact.Event.MouseEnter] = state.onMouseEnter, + [Roact.Event.MouseLeave] = state.onMouseLeave, + }, { + Icon = Roact.createElement("ImageLabel", { + AnchorPoint = Vector2.new(0.5, 0.5), + BackgroundTransparency = 1, + Image = props.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, + Text = ("R$ %d"):format(props.price), + TextColor3 = Color3.fromRGB(10, 200, 10), + TextScaled = true, + Position = UDim2.new(0.5, 0, 1, 0), + Size = UDim2.new(1, 0, 0.3, 0), + }), + }) +end + +return ProductItem \ No newline at end of file diff --git a/examples/shop-page/Components/ProductItemList.lua b/examples/shop-page/Components/ProductItemList.lua new file mode 100644 index 00000000..87a2dde2 --- /dev/null +++ b/examples/shop-page/Components/ProductItemList.lua @@ -0,0 +1,35 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Components = ReplicatedStorage:WaitForChild("Components") + +local Roact = require(ReplicatedStorage.Roact) + +local ProductItem = require(Components:WaitForChild("ProductItem")) + +--[[ + Props: { + items: list of product items following this structure + { + identifier = string, + price = number, + order = optional number, + } + } +]] +local function ProductItemList(props) + local elements = {} + + for i=1, #props.items do + local item = props.items[i] + + elements[item.identifier] = Roact.createElement(ProductItem, { + image = item.image, + price = item.price, + order = item.order, + }) + end + + return Roact.createFragment(elements) +end + +return ProductItemList \ No newline at end of file diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua new file mode 100644 index 00000000..baae33c7 --- /dev/null +++ b/examples/shop-page/Components/ShopPage.lua @@ -0,0 +1,77 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +local Components = ReplicatedStorage:WaitForChild("Components") + +local Roact = require(ReplicatedStorage.Roact) + +local ProductItemList = require(Components:WaitForChild("ProductItemList")) + +local excludePropsForFrame = { + items = true, + itemsPerRow = true, + itemAspectRatio = true, + padding = true +} + +local ShopPage = Roact.Component:extend("ShopPage") + +function ShopPage:init() + self:setState({ + cellSize = 100, + canvasHeight = 100, + }) + + self.onAbsoluteSizeChanged = function(frame) + local props = self.props + local state = self.state + local padding = props.padding or 0 + 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) + + self:setState({ + cellSize = cellWidth, + canvasHeight = canvasHeight, + }) + end +end + +function ShopPage:render() + local props = self.props + local state = self.state + local padding = props.padding or 0 + local cellSize = state.cellSize + local canvasHeight = state.canvasHeight + + local frameProps = {} + + for key, value in pairs(props) do + if not excludePropsForFrame[key] then + frameProps[key] = value + end + end + + frameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) + frameProps.ClipsDescendants = true + frameProps[Roact.Change.AbsoluteSize] = self.onAbsoluteSizeChanged + + return Roact.createElement("ScrollingFrame", frameProps, { + UIGrid = Roact.createElement("UIGridLayout", { + CellPadding = UDim2.new(0, padding, 0, padding), + CellSize = UDim2.new(0, cellSize, 0, cellSize * props.itemAspectRatio), + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, + }), + ProductItems = Roact.createElement(ProductItemList, { + items = props.items + }), + }) +end + +return ShopPage \ No newline at end of file diff --git a/examples/shop-page/default.project.json b/examples/shop-page/default.project.json new file mode 100644 index 00000000..b41fce69 --- /dev/null +++ b/examples/shop-page/default.project.json @@ -0,0 +1,18 @@ +{ + "name": "Roact Test Place", + "tree": { + "$className": "DataModel", + "ReplicatedFirst": { + "$className": "ReplicatedFirst", + "main": { + "$path": "main.client.lua" + } + }, + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Components": { + "$path": "Components" + } + } + } +} \ No newline at end of file diff --git a/examples/shop-page/main.client.lua b/examples/shop-page/main.client.lua new file mode 100644 index 00000000..99c3bc85 --- /dev/null +++ b/examples/shop-page/main.client.lua @@ -0,0 +1,40 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +local Components = ReplicatedStorage:WaitForChild("Components") + +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 = { + { + identifier = "SmallCoinPack", + image = "https://www.roblox.com/bust-thumbnail/image?userId=3370645&width=420&height=420&format=png", + price = 30, + }, + { + identifier = "LargeCoinPack", + image = "https://www.roblox.com/bust-thumbnail/image?userId=3370645&width=420&height=420&format=png", + price = 50, + }, + { + identifier = "MediumCoinPack", + image = "https://www.roblox.com/bust-thumbnail/image?userId=3370645&width=420&height=420&format=png", + price = 40, + }, + }, + itemAspectRatio = 1, + itemsPerRow = 3, + padding = 10, + 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), +}) + +local handle = Roact.mount(shopPage, screenGui) From b46e782c9e3d1675b00252cd7551d63c45a9d61c Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Fri, 21 Jun 2019 10:49:59 -0700 Subject: [PATCH 02/16] Use validateProps and small fixes Add cooler demo items image --- docs/tutorials/shop-page.md | 1 + examples/shop-page/Components/ProductItem.lua | 39 ++++++++++----- .../shop-page/Components/ProductItemList.lua | 11 +---- examples/shop-page/Components/ShopPage.lua | 34 +++++++++----- examples/shop-page/default.project.json | 11 +++-- examples/shop-page/main.client.lua | 47 +++++++++++++++---- 6 files changed, 93 insertions(+), 50 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index ad07a544..b4123564 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -18,6 +18,7 @@ Let's start by creating the top level component, that will be the page containin ## Product Item [remind state and props] +[props validation] ### Adding some animations diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index 7369b628..78f933d4 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -6,18 +6,6 @@ local Roact = require(ReplicatedStorage.Roact) local ProductItem = Roact.Component:extend("ProductItem") ---[[ - Props: { - image: the image displayed - price: how much robux the product cost - productId: the developer product id - - -- Optional props - order: the index at which the item should be shown. If not - provided, the price will be used as the index. - } -]] - function ProductItem:init() self:setState({ onMouseEnter = function() @@ -42,8 +30,9 @@ function ProductItem:render() return Roact.createElement("ImageButton", { BackgroundTransparency = 1, - Image = '', + Image = "", LayoutOrder = props.order or props.price, + [Roact.Event.Activated] = state.onActivated, [Roact.Event.MouseEnter] = state.onMouseEnter, [Roact.Event.MouseLeave] = state.onMouseLeave, }, { @@ -57,13 +46,37 @@ function ProductItem:render() PriceLabel = Roact.createElement("TextLabel", { AnchorPoint = Vector2.new(0.5, 1), BackgroundTransparency = 1, + Font = Enum.Font.SourceSans, Text = ("R$ %d"):format(props.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 +function ProductItem.validateProps(props) + return pcall(function() + assert( + type(props.image) == "string", + ("props.image should be a string (got %q)"):format(type(props.image)) + ) + assert( + type(props.price) == "number", + ("props.price should be a number (got %q)"):format(type(props.price)) + ) + assert( + type(props.productId) == "string" or type(props.productId) == "number", + ("props.productId should be a string or a number (got %q)"):format(type(props.productId)) + ) + assert( + props.order == nil or type(props.order) == "number", + ("props.order should be a number (got %q)"):format(type(props.order)) + ) + end) +end + return ProductItem \ No newline at end of file diff --git a/examples/shop-page/Components/ProductItemList.lua b/examples/shop-page/Components/ProductItemList.lua index 87a2dde2..f5f7f39d 100644 --- a/examples/shop-page/Components/ProductItemList.lua +++ b/examples/shop-page/Components/ProductItemList.lua @@ -6,16 +6,6 @@ local Roact = require(ReplicatedStorage.Roact) local ProductItem = require(Components:WaitForChild("ProductItem")) ---[[ - Props: { - items: list of product items following this structure - { - identifier = string, - price = number, - order = optional number, - } - } -]] local function ProductItemList(props) local elements = {} @@ -25,6 +15,7 @@ local function ProductItemList(props) elements[item.identifier] = Roact.createElement(ProductItem, { image = item.image, price = item.price, + productId = item.productId, order = item.order, }) end diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index baae33c7..b18efa2a 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -7,15 +7,12 @@ local Roact = require(ReplicatedStorage.Roact) local ProductItemList = require(Components:WaitForChild("ProductItemList")) -local excludePropsForFrame = { - items = true, - itemsPerRow = true, - itemAspectRatio = true, - padding = true -} - local ShopPage = Roact.Component:extend("ShopPage") +ShopPage.defaultProps = { + padding = 0, +} + function ShopPage:init() self:setState({ cellSize = 100, @@ -25,7 +22,7 @@ function ShopPage:init() self.onAbsoluteSizeChanged = function(frame) local props = self.props local state = self.state - local padding = props.padding or 0 + local padding = props.padding local itemsPerRow = props.itemsPerRow local totalWidth = frame.AbsoluteSize.X @@ -44,16 +41,14 @@ end function ShopPage:render() local props = self.props local state = self.state - local padding = props.padding or 0 + local padding = props.padding local cellSize = state.cellSize local canvasHeight = state.canvasHeight local frameProps = {} - for key, value in pairs(props) do - if not excludePropsForFrame[key] then - frameProps[key] = value - end + for key, value in pairs(props.frameProps) do + frameProps[key] = value end frameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) @@ -74,4 +69,17 @@ function ShopPage:render() }) end +function ShopPage.validateProps(props) + return pcall(function() + assert( + type(props.padding) == "number", + ("props.padding should be a number (got %q)"):format(type(props.padding)) + ) + assert( + type(props.frameProps) == "table", + ("props.frameProps should be a table (got %q)"):format(type(props.frameProps)) + ) + end) +end + return ShopPage \ No newline at end of file diff --git a/examples/shop-page/default.project.json b/examples/shop-page/default.project.json index b41fce69..d86089b2 100644 --- a/examples/shop-page/default.project.json +++ b/examples/shop-page/default.project.json @@ -2,10 +2,13 @@ "name": "Roact Test Place", "tree": { "$className": "DataModel", - "ReplicatedFirst": { - "$className": "ReplicatedFirst", - "main": { - "$path": "main.client.lua" + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "main": { + "$path": "main.client.lua" + } } }, "ReplicatedStorage": { diff --git a/examples/shop-page/main.client.lua b/examples/shop-page/main.client.lua index 99c3bc85..7394ddff 100644 --- a/examples/shop-page/main.client.lua +++ b/examples/shop-page/main.client.lua @@ -5,6 +5,10 @@ local Components = ReplicatedStorage:WaitForChild("Components") local Roact = require(ReplicatedStorage.Roact) +Roact.setGlobalConfig({ + propValidation = true +}) + local ShopPage = require(Components:WaitForChild("ShopPage")) local screenGui = Instance.new("ScreenGui") @@ -13,28 +17,51 @@ screenGui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") local shopPage = Roact.createElement(ShopPage, { items = { { - identifier = "SmallCoinPack", - image = "https://www.roblox.com/bust-thumbnail/image?userId=3370645&width=420&height=420&format=png", + identifier = "Red Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=139618072&width=420&height=420&format=png", price = 30, + productId = 0, }, { - identifier = "LargeCoinPack", - image = "https://www.roblox.com/bust-thumbnail/image?userId=3370645&width=420&height=420&format=png", + identifier = "Green Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=20264649&width=420&height=420&format=png", price = 50, + productId = 0, }, { - identifier = "MediumCoinPack", - image = "https://www.roblox.com/bust-thumbnail/image?userId=3370645&width=420&height=420&format=png", + identifier = "Yellow Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=7135977&width=420&height=420&format=png", price = 40, + productId = 0, + }, + { + identifier = "Blue Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=1459035&width=420&height=420&format=png", + price = 55, + productId = 0, + }, + { + identifier = "Dark Blue Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=68268372&width=420&height=420&format=png", + price = 60, + productId = 0, + }, + { + identifier = "Purple Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=334661971&width=420&height=420&format=png", + price = 100, + productId = 0, }, }, itemAspectRatio = 1, itemsPerRow = 3, padding = 10, - 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), + frameProps = { + 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), + }, }) local handle = Roact.mount(shopPage, screenGui) From 37f35a1ec04993f83036f705bdb1af7ada1d765d Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Fri, 21 Jun 2019 11:06:28 -0700 Subject: [PATCH 03/16] Move validateProps to top --- examples/shop-page/Components/ProductItem.lua | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index 78f933d4..d31059f5 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -6,6 +6,27 @@ local Roact = require(ReplicatedStorage.Roact) local ProductItem = Roact.Component:extend("ProductItem") +function ProductItem.validateProps(props) + return pcall(function() + assert( + type(props.image) == "string", + ("props.image should be a string (got %q)"):format(type(props.image)) + ) + assert( + type(props.price) == "number", + ("props.price should be a number (got %q)"):format(type(props.price)) + ) + assert( + type(props.productId) == "string" or type(props.productId) == "number", + ("props.productId should be a string or a number (got %q)"):format(type(props.productId)) + ) + assert( + props.order == nil or type(props.order) == "number", + ("props.order should be a number (got %q)"):format(type(props.order)) + ) + end) +end + function ProductItem:init() self:setState({ onMouseEnter = function() @@ -58,25 +79,4 @@ function ProductItem:render() }) end -function ProductItem.validateProps(props) - return pcall(function() - assert( - type(props.image) == "string", - ("props.image should be a string (got %q)"):format(type(props.image)) - ) - assert( - type(props.price) == "number", - ("props.price should be a number (got %q)"):format(type(props.price)) - ) - assert( - type(props.productId) == "string" or type(props.productId) == "number", - ("props.productId should be a string or a number (got %q)"):format(type(props.productId)) - ) - assert( - props.order == nil or type(props.order) == "number", - ("props.order should be a number (got %q)"):format(type(props.order)) - ) - end) -end - return ProductItem \ No newline at end of file From f0dd2b9d24fc592163ff2aed38d66f5e38124c4a Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Mon, 24 Jun 2019 09:43:56 -0700 Subject: [PATCH 04/16] Move validateProps to top of file --- examples/shop-page/Components/ShopPage.lua | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index b18efa2a..fd00977c 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -1,5 +1,4 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Players = game:GetService("Players") local Components = ReplicatedStorage:WaitForChild("Components") @@ -13,6 +12,23 @@ ShopPage.defaultProps = { padding = 0, } +function ShopPage.validateProps(props) + return pcall(function() + assert( + type(props.padding) == "number", + ("props.padding should be a number (got %q)"):format(type(props.padding)) + ) + assert( + type(props.frameProps) == "table", + ("props.frameProps should be a table (got %q)"):format(type(props.frameProps)) + ) + assert( + type(props.padding) == "number", + ("props.padding should be a number (got %q)"):format(type(props.padding)) + ) + end) +end + function ShopPage:init() self:setState({ cellSize = 100, @@ -21,7 +37,6 @@ function ShopPage:init() self.onAbsoluteSizeChanged = function(frame) local props = self.props - local state = self.state local padding = props.padding local itemsPerRow = props.itemsPerRow @@ -69,17 +84,4 @@ function ShopPage:render() }) end -function ShopPage.validateProps(props) - return pcall(function() - assert( - type(props.padding) == "number", - ("props.padding should be a number (got %q)"):format(type(props.padding)) - ) - assert( - type(props.frameProps) == "table", - ("props.frameProps should be a table (got %q)"):format(type(props.frameProps)) - ) - end) -end - return ShopPage \ No newline at end of file From bb66313020e5f25f7586e97383c6095577aee74f Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Fri, 28 Jun 2019 17:00:37 -0700 Subject: [PATCH 05/16] Remove props validation --- examples/shop-page/Components/ProductItem.lua | 21 ------------------- examples/shop-page/Components/ShopPage.lua | 17 --------------- 2 files changed, 38 deletions(-) diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index d31059f5..cf5806f2 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -6,27 +6,6 @@ local Roact = require(ReplicatedStorage.Roact) local ProductItem = Roact.Component:extend("ProductItem") -function ProductItem.validateProps(props) - return pcall(function() - assert( - type(props.image) == "string", - ("props.image should be a string (got %q)"):format(type(props.image)) - ) - assert( - type(props.price) == "number", - ("props.price should be a number (got %q)"):format(type(props.price)) - ) - assert( - type(props.productId) == "string" or type(props.productId) == "number", - ("props.productId should be a string or a number (got %q)"):format(type(props.productId)) - ) - assert( - props.order == nil or type(props.order) == "number", - ("props.order should be a number (got %q)"):format(type(props.order)) - ) - end) -end - function ProductItem:init() self:setState({ onMouseEnter = function() diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index fd00977c..90b4ee35 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -12,23 +12,6 @@ ShopPage.defaultProps = { padding = 0, } -function ShopPage.validateProps(props) - return pcall(function() - assert( - type(props.padding) == "number", - ("props.padding should be a number (got %q)"):format(type(props.padding)) - ) - assert( - type(props.frameProps) == "table", - ("props.frameProps should be a table (got %q)"):format(type(props.frameProps)) - ) - assert( - type(props.padding) == "number", - ("props.padding should be a number (got %q)"):format(type(props.padding)) - ) - end) -end - function ShopPage:init() self:setState({ cellSize = 100, From b86ec48ce9e4e2a724a3b19f9965b50fc2b8f576 Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Fri, 28 Jun 2019 17:11:25 -0700 Subject: [PATCH 06/16] Move props in top of render --- examples/shop-page/Components/ProductItem.lua | 10 +++++++--- examples/shop-page/Components/ShopPage.lua | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index cf5806f2..1d97ec13 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -28,10 +28,14 @@ function ProductItem:render() local state = self.state local padding = self.padding + local image = props.image + local price = props.price + local order = props.order or price + return Roact.createElement("ImageButton", { BackgroundTransparency = 1, Image = "", - LayoutOrder = props.order or props.price, + LayoutOrder = order, [Roact.Event.Activated] = state.onActivated, [Roact.Event.MouseEnter] = state.onMouseEnter, [Roact.Event.MouseLeave] = state.onMouseLeave, @@ -39,7 +43,7 @@ function ProductItem:render() Icon = Roact.createElement("ImageLabel", { AnchorPoint = Vector2.new(0.5, 0.5), BackgroundTransparency = 1, - Image = props.image, + Image = image, Position = UDim2.new(0.5, 0, 0.5, 0), Size = UDim2.new(1, padding, 1, padding), }), @@ -47,7 +51,7 @@ function ProductItem:render() AnchorPoint = Vector2.new(0.5, 1), BackgroundTransparency = 1, Font = Enum.Font.SourceSans, - Text = ("R$ %d"):format(props.price), + Text = ("R$ %d"):format(price), TextColor3 = Color3.fromRGB(10, 200, 10), TextScaled = true, TextStrokeTransparency = 0, diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index 90b4ee35..d28b47b8 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -43,6 +43,9 @@ function ShopPage:render() local cellSize = state.cellSize local canvasHeight = state.canvasHeight + local itemAspectRatio = props.itemAspectRatio + local items = props.items + local frameProps = {} for key, value in pairs(props.frameProps) do @@ -56,13 +59,13 @@ function ShopPage:render() return Roact.createElement("ScrollingFrame", frameProps, { UIGrid = Roact.createElement("UIGridLayout", { CellPadding = UDim2.new(0, padding, 0, padding), - CellSize = UDim2.new(0, cellSize, 0, cellSize * props.itemAspectRatio), + CellSize = UDim2.new(0, cellSize, 0, cellSize * itemAspectRatio), HorizontalAlignment = Enum.HorizontalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder, }), ProductItems = Roact.createElement(ProductItemList, { - items = props.items + items = items }), }) end From 07e85936041810ee11b28f54b0d0526134c77060 Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Fri, 28 Jun 2019 18:01:27 -0700 Subject: [PATCH 07/16] Add hover animations --- examples/shop-page/Components/ProductItem.lua | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index 1d97ec13..11d48064 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -1,18 +1,21 @@ local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") +local TweenService = game:GetService("TweenService") local Roact = require(ReplicatedStorage.Roact) local ProductItem = Roact.Component:extend("ProductItem") +local PADDING = 20 + function ProductItem:init() self:setState({ onMouseEnter = function() - -- grow the icon a little + self.toBigIcon:Play() end, onMouseLeave = function() - -- go back to original size + self.toNormalIcon:Play() end, onActivated = function() local props = self.props @@ -20,13 +23,12 @@ function ProductItem:init() MarketplaceService:PromptProductPurchase(Players.LocalPlayer, props.productId) end }) - self.padding, self.updatePadding = Roact.createBinding(0) + self.ref = Roact.createRef() end function ProductItem:render() local props = self.props local state = self.state - local padding = self.padding local image = props.image local price = props.price @@ -45,7 +47,8 @@ function ProductItem:render() BackgroundTransparency = 1, Image = image, Position = UDim2.new(0.5, 0, 0.5, 0), - Size = UDim2.new(1, padding, 1, padding), + Size = UDim2.new(1, -PADDING, 1, -PADDING), + [Roact.Ref] = self.ref, }), PriceLabel = Roact.createElement("TextLabel", { AnchorPoint = Vector2.new(0.5, 1), @@ -62,4 +65,21 @@ function ProductItem:render() }) end +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 + +function ProductItem:willUnmount() + self.toBigIcon:Destroy() + self.toNormalIcon:Destroy() +end + return ProductItem \ No newline at end of file From 8b62a91f9dbbc4631271265bc6e9362baf3faba9 Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Wed, 3 Jul 2019 16:37:36 -0700 Subject: [PATCH 08/16] Begin tutorial docs --- docs/tutorials/shop-page.md | 73 +++++++++++++++++++++- examples/shop-page/Components/ShopPage.lua | 20 +++--- examples/shop-page/main.client.lua | 4 -- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index b4123564..fcf6487f 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -4,14 +4,85 @@ _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. +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, + }), + ProductItems = Roact.createElement(ProductItemList, { + items = items + }), + }) +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") + +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 ## Items Grid diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index d28b47b8..75f3efe0 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -39,24 +39,26 @@ end function ShopPage:render() local props = self.props local state = self.state + + local items = props.items + local itemAspectRatio = props.itemAspectRatio + local frameProps = props.frameProps local padding = props.padding + local cellSize = state.cellSize local canvasHeight = state.canvasHeight - local itemAspectRatio = props.itemAspectRatio - local items = props.items - - local frameProps = {} + local newFrameProps = {} - for key, value in pairs(props.frameProps) do + for key, value in pairs(frameProps) do frameProps[key] = value end - frameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) - frameProps.ClipsDescendants = true - frameProps[Roact.Change.AbsoluteSize] = self.onAbsoluteSizeChanged + newFrameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) + newFrameProps.ClipsDescendants = true + newFrameProps[Roact.Change.AbsoluteSize] = self.onAbsoluteSizeChanged - return Roact.createElement("ScrollingFrame", frameProps, { + return Roact.createElement("ScrollingFrame", newFrameProps, { UIGrid = Roact.createElement("UIGridLayout", { CellPadding = UDim2.new(0, padding, 0, padding), CellSize = UDim2.new(0, cellSize, 0, cellSize * itemAspectRatio), diff --git a/examples/shop-page/main.client.lua b/examples/shop-page/main.client.lua index 7394ddff..b8ce0e1c 100644 --- a/examples/shop-page/main.client.lua +++ b/examples/shop-page/main.client.lua @@ -5,10 +5,6 @@ local Components = ReplicatedStorage:WaitForChild("Components") local Roact = require(ReplicatedStorage.Roact) -Roact.setGlobalConfig({ - propValidation = true -}) - local ShopPage = require(Components:WaitForChild("ShopPage")) local screenGui = Instance.new("ScreenGui") From 04371946e2ff2d84736245d7b1fa3224ddac9cbe Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Mon, 8 Jul 2019 14:21:26 -0700 Subject: [PATCH 09/16] Improve tutorial content --- docs/tutorials/shop-page.md | 72 +++++++++++++++++-- .../shop-page/Components/ProductItemList.lua | 6 +- examples/shop-page/main.client.lua | 2 +- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index fcf6487f..26c6d9f1 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -15,9 +15,9 @@ In our case, the ShopPage component we're making will receive it's products from > 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 + - __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") @@ -37,9 +37,6 @@ function ShopPage:render() VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder, }), - ProductItems = Roact.createElement(ProductItemList, { - items = items - }), }) end @@ -84,8 +81,69 @@ 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 -## Items Grid +## 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 + +### 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 yet, but we will suppose it is there for now. + +```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, they are used to group multiple elements together. ## Product Item [remind state and props] diff --git a/examples/shop-page/Components/ProductItemList.lua b/examples/shop-page/Components/ProductItemList.lua index f5f7f39d..92a8ef8c 100644 --- a/examples/shop-page/Components/ProductItemList.lua +++ b/examples/shop-page/Components/ProductItemList.lua @@ -7,10 +7,12 @@ local Roact = require(ReplicatedStorage.Roact) local ProductItem = require(Components:WaitForChild("ProductItem")) local function ProductItemList(props) + local items = props.items + local elements = {} - for i=1, #props.items do - local item = props.items[i] + for i=1, #items do + local item = items[i] elements[item.identifier] = Roact.createElement(ProductItem, { image = item.image, diff --git a/examples/shop-page/main.client.lua b/examples/shop-page/main.client.lua index b8ce0e1c..8cddf8a8 100644 --- a/examples/shop-page/main.client.lua +++ b/examples/shop-page/main.client.lua @@ -60,4 +60,4 @@ local shopPage = Roact.createElement(ShopPage, { }, }) -local handle = Roact.mount(shopPage, screenGui) +Roact.mount(shopPage, screenGui) From 6e2edfdd584f2d1186a94fddd854f675d8a1e381 Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Wed, 24 Jul 2019 19:00:29 -0700 Subject: [PATCH 10/16] Add product item content --- docs/tutorials/shop-page.md | 76 ++++++++++++++++++- examples/shop-page/Components/ProductItem.lua | 29 ++++--- examples/shop-page/Components/ShopPage.lua | 2 +- 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index 26c6d9f1..850cc4b3 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -91,7 +91,7 @@ To keep our component simple, let's divide this feature into different component ### 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 yet, but we will suppose it is there for now. +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. ```lua local ReplicatedStorage = game:GetService("ReplicatedStorage") @@ -143,11 +143,79 @@ return Roact.createElement("ScrollingFrame", frameProps, { > Fragments > -> In case you have forgotten about fragments, they are used to group multiple elements together. +> 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 -[remind state and props] -[props validation] +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 +``` + +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_ 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, +}, { + ... +} +``` ### Adding some animations diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index 11d48064..df9d2aff 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -10,25 +10,22 @@ local ProductItem = Roact.Component:extend("ProductItem") local PADDING = 20 function ProductItem:init() - self:setState({ - onMouseEnter = function() - self.toBigIcon:Play() - end, - onMouseLeave = function() - self.toNormalIcon:Play() - end, - onActivated = function() - local props = self.props + self.onMouseEnter = function() + self.toBigIcon:Play() + end + self.onMouseLeave = function() + self.toNormalIcon:Play() + end + self.onActivated = function() + local props = self.props - MarketplaceService:PromptProductPurchase(Players.LocalPlayer, props.productId) - end - }) + MarketplaceService:PromptProductPurchase(Players.LocalPlayer, props.productId) + end self.ref = Roact.createRef() end function ProductItem:render() local props = self.props - local state = self.state local image = props.image local price = props.price @@ -38,9 +35,9 @@ function ProductItem:render() BackgroundTransparency = 1, Image = "", LayoutOrder = order, - [Roact.Event.Activated] = state.onActivated, - [Roact.Event.MouseEnter] = state.onMouseEnter, - [Roact.Event.MouseLeave] = state.onMouseLeave, + [Roact.Event.Activated] = self.onActivated, + [Roact.Event.MouseEnter] = self.onMouseEnter, + [Roact.Event.MouseLeave] = self.onMouseLeave, }, { Icon = Roact.createElement("ImageLabel", { AnchorPoint = Vector2.new(0.5, 0.5), diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index 75f3efe0..447586f5 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -51,7 +51,7 @@ function ShopPage:render() local newFrameProps = {} for key, value in pairs(frameProps) do - frameProps[key] = value + newFrameProps[key] = value end newFrameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) From c526ae546cf15428e4f7a35d0b83c8187ee52d9e Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Thu, 25 Jul 2019 15:26:28 -0700 Subject: [PATCH 11/16] Improve tutorial --- docs/tutorials/shop-page.md | 154 +++++++++++++++++- examples/shop-page/Components/ProductItem.lua | 2 +- examples/shop-page/Components/ShopPage.lua | 1 - 3 files changed, 147 insertions(+), 10 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index 850cc4b3..0b6794b2 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -15,9 +15,9 @@ In our case, the ShopPage component we're making will receive it's products from > 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 + - **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") @@ -81,13 +81,100 @@ 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 + - **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 ### ProductItemList @@ -141,7 +228,7 @@ return Roact.createElement("ScrollingFrame", frameProps, { }) ``` -> Fragments +> **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. @@ -190,7 +277,7 @@ end return ProductItem ``` -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_ lifecycle method. +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") @@ -216,9 +303,60 @@ return Roact.createElement("ImageButton", { ... } ``` +> **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 +``` -### Adding some animations +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() +end +``` ## Integration diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/ProductItem.lua index df9d2aff..bbadf32a 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/ProductItem.lua @@ -10,6 +10,7 @@ local ProductItem = Roact.Component:extend("ProductItem") local PADDING = 20 function ProductItem:init() + self.ref = Roact.createRef() self.onMouseEnter = function() self.toBigIcon:Play() end @@ -21,7 +22,6 @@ function ProductItem:init() MarketplaceService:PromptProductPurchase(Players.LocalPlayer, props.productId) end - self.ref = Roact.createRef() end function ProductItem:render() diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index 447586f5..bc89562d 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -55,7 +55,6 @@ function ShopPage:render() end newFrameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) - newFrameProps.ClipsDescendants = true newFrameProps[Roact.Change.AbsoluteSize] = self.onAbsoluteSizeChanged return Roact.createElement("ScrollingFrame", newFrameProps, { From 2908bed8944f7973303eec54f52bac2d4195fbd9 Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Fri, 26 Jul 2019 15:53:54 -0700 Subject: [PATCH 12/16] Add shop-page to examples demo place --- docs/tutorials/shop-page.md | 29 +++++++++ examples/init.client.lua | 4 ++ .../shop-page/Components/ProductItemList.lua | 2 +- examples/shop-page/Components/ShopPage.lua | 3 +- examples/shop-page/Products.lua | 38 +++++++++++ examples/shop-page/default.project.json | 21 ------- examples/shop-page/init.lua | 35 +++++++++++ examples/shop-page/main.client.lua | 63 ------------------- 8 files changed, 108 insertions(+), 87 deletions(-) create mode 100644 examples/shop-page/Products.lua delete mode 100644 examples/shop-page/default.project.json create mode 100644 examples/shop-page/init.lua delete mode 100644 examples/shop-page/main.client.lua diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index 0b6794b2..0b9f9745 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -180,6 +180,12 @@ To keep our component simple, let's divide this feature into different component 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") @@ -277,6 +283,29 @@ 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 diff --git a/examples/init.client.lua b/examples/init.client.lua index a320546c..9f6bf22e 100644 --- a/examples/init.client.lua +++ b/examples/init.client.lua @@ -29,6 +29,10 @@ local exampleData = { name = "binding", label = "Binding", }, + { + name = "shop-page", + label = "Shop Page", + }, } for _, example in ipairs(exampleData) do diff --git a/examples/shop-page/Components/ProductItemList.lua b/examples/shop-page/Components/ProductItemList.lua index 92a8ef8c..dfa517fe 100644 --- a/examples/shop-page/Components/ProductItemList.lua +++ b/examples/shop-page/Components/ProductItemList.lua @@ -1,6 +1,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Components = ReplicatedStorage:WaitForChild("Components") +local Components = script.Parent local Roact = require(ReplicatedStorage.Roact) diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index bc89562d..08e5eb04 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -1,9 +1,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Components = ReplicatedStorage:WaitForChild("Components") +local Components = script.Parent local Roact = require(ReplicatedStorage.Roact) - local ProductItemList = require(Components:WaitForChild("ProductItemList")) local ShopPage = Roact.Component:extend("ShopPage") diff --git a/examples/shop-page/Products.lua b/examples/shop-page/Products.lua new file mode 100644 index 00000000..80cc6ae1 --- /dev/null +++ b/examples/shop-page/Products.lua @@ -0,0 +1,38 @@ +return { + { + identifier = "Red Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=139618072&width=420&height=420&format=png", + price = 30, + productId = 0, + }, + { + identifier = "Green Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=20264649&width=420&height=420&format=png", + price = 50, + productId = 0, + }, + { + identifier = "Yellow Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=7135977&width=420&height=420&format=png", + price = 40, + productId = 0, + }, + { + identifier = "Blue Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=1459035&width=420&height=420&format=png", + price = 55, + productId = 0, + }, + { + identifier = "Dark Blue Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=68268372&width=420&height=420&format=png", + price = 60, + productId = 0, + }, + { + identifier = "Purple Visor", + image = "https://www.roblox.com/asset-thumbnail/image?assetId=334661971&width=420&height=420&format=png", + price = 100, + productId = 0, + }, +} \ No newline at end of file diff --git a/examples/shop-page/default.project.json b/examples/shop-page/default.project.json deleted file mode 100644 index d86089b2..00000000 --- a/examples/shop-page/default.project.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Roact Test Place", - "tree": { - "$className": "DataModel", - "StarterPlayer": { - "$className": "StarterPlayer", - "StarterPlayerScripts": { - "$className": "StarterPlayerScripts", - "main": { - "$path": "main.client.lua" - } - } - }, - "ReplicatedStorage": { - "$className": "ReplicatedStorage", - "Components": { - "$path": "Components" - } - } - } -} \ No newline at end of file diff --git a/examples/shop-page/init.lua b/examples/shop-page/init.lua new file mode 100644 index 00000000..1324b026 --- /dev/null +++ b/examples/shop-page/init.lua @@ -0,0 +1,35 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +local Roact = require(ReplicatedStorage.Roact) + +local Products = require(script:WaitForChild("Products")) + +local Components = script:WaitForChild("Components") +local ShopPage = require(Components:WaitForChild("ShopPage")) + +return function() + local screenGui = Instance.new("ScreenGui") + screenGui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") + + local shopPage = Roact.createElement(ShopPage, { + items = Products, + itemAspectRatio = 1, + itemsPerRow = 3, + padding = 10, + frameProps = { + 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), + }, + }) + + local handle = Roact.mount(shopPage, screenGui) + + local function stop() + Roact.unmount(handle) + end + + return stop +end \ No newline at end of file diff --git a/examples/shop-page/main.client.lua b/examples/shop-page/main.client.lua deleted file mode 100644 index 8cddf8a8..00000000 --- a/examples/shop-page/main.client.lua +++ /dev/null @@ -1,63 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Players = game:GetService("Players") - -local Components = ReplicatedStorage:WaitForChild("Components") - -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 = { - { - identifier = "Red Visor", - image = "https://www.roblox.com/asset-thumbnail/image?assetId=139618072&width=420&height=420&format=png", - price = 30, - productId = 0, - }, - { - identifier = "Green Visor", - image = "https://www.roblox.com/asset-thumbnail/image?assetId=20264649&width=420&height=420&format=png", - price = 50, - productId = 0, - }, - { - identifier = "Yellow Visor", - image = "https://www.roblox.com/asset-thumbnail/image?assetId=7135977&width=420&height=420&format=png", - price = 40, - productId = 0, - }, - { - identifier = "Blue Visor", - image = "https://www.roblox.com/asset-thumbnail/image?assetId=1459035&width=420&height=420&format=png", - price = 55, - productId = 0, - }, - { - identifier = "Dark Blue Visor", - image = "https://www.roblox.com/asset-thumbnail/image?assetId=68268372&width=420&height=420&format=png", - price = 60, - productId = 0, - }, - { - identifier = "Purple Visor", - image = "https://www.roblox.com/asset-thumbnail/image?assetId=334661971&width=420&height=420&format=png", - price = 100, - productId = 0, - }, - }, - itemAspectRatio = 1, - itemsPerRow = 3, - padding = 10, - frameProps = { - 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), - }, -}) - -Roact.mount(shopPage, screenGui) From f00e5ae966203e6090a5229786048edb8c84a18f Mon Sep 17 00:00:00 2001 From: David Minnerly Date: Sat, 27 Jul 2019 16:23:05 -0700 Subject: [PATCH 13/16] Revise wording so things flow better --- docs/tutorials/shop-page.md | 78 ++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index 0b9f9745..8b240d8c 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -3,21 +3,22 @@ _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. +## The ShopPage 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. +Let's start by creating the top level component, which will be the page that contains the items for sale in the game. When creating a new component, we need to think about what this component will manage and what will be given to that component from the parent component. + +In our case, the ShopPage component we're making will receive its 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. +> This is a question that often comes to our mind when we start working with 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. +> On the other hand, props are used to pass data from parent components to child components. It implies that both parent component and its child are aware of the structure of the props dictionary and depend 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 + - **frameProps:** a dictionary of props that will be passed to the ScrollingFrame to change its visuals + - **items:** a list of objects that represent the products we want to sell + - **padding:** the number of pixels between each of our items in the shop ```lua local ShopPage = Roact.Component:extend("ShopPage") @@ -43,11 +44,11 @@ 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. +As you can see, `frameProps` is used when creating the ScrollingFrame! This way, we can control how the container looks 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. +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 let us view our component. This script needs to _mount_ our ShopPage into a parent, which will simply be a ScreenGui. ```lua local ReplicatedStorage = game:GetService("ReplicatedStorage") @@ -65,7 +66,7 @@ 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 + -- here we define the properties of the ScrollingFrame for our ShopPage AnchorPoint = Vector2.new(0.5, 0.5), BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 0.5, 0), @@ -78,16 +79,16 @@ 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 + - An element is created from our ShopPage component, where we specified the different props we want + - `Roact.mount` will create the real GUI instances 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. +We want to adjust the ScrollingFrame so its *CanvasSize* property always fits 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. +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 ScrollingFrame's *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() @@ -119,7 +120,7 @@ function ShopPage:render() newFrameProps[key] = value end - -- now that we have cloned the scrolling frame props, we add new entries + -- now that we have cloned the ScrollingFrame props, we add new entries newFrameProps.CanvasSize = UDim2.new(0, 0, 0, canvasHeight) return Roact.createElement("ScrollingFrame", newFrameProps, { @@ -135,7 +136,7 @@ function ShopPage:render() 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. +Then, we need to update the state when the ScrollingFrame size changes. To connect a function to a changed property event, we use [`Roact.Change`](https://roblox.github.io/roact/api-reference/#roactchange). In our case, we will listen to the `AbsoluteSize` property change of the ScrollingFrame. The cell size of the grid will be computed to maximize the horizontal space. After, the aspect ratio of the items are used to find the vertical space needed to contain all of them. ```lua function ShopPage:init() @@ -170,21 +171,21 @@ 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. +In the previous section we created a ScrollingFrame 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 + - **ProductItem:** a card that shows the price of an item with its image, and when clicked prompts the player to buy the product - **ProductItemList:** iterate on the list of items to create each individual ProductItem element ### 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. +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 made our ProductItem component yet, but we will pretend 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. +We will define the props that the ProductItem will use to render its component. We will make the ProductItem component accept these props: +- **image:** the image representing the product that the component will display. +- **price:** how many robux the product costs. +- **productId:** the identifier of the developer product. +- **order:** used 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") @@ -217,6 +218,10 @@ end return ProductItemList ``` +> **Fragments** +> +> Elements contained in a Fragment are parented to the Fragment's parent. That way responsibilities of the grid view can be split from the content. The ShopPage component will handle the grid view, so it owns the *UIGridLayout* object, and it will give the responsibility to the *ProductItemList* component to take care of the content. + 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 @@ -234,11 +239,8 @@ return Roact.createElement("ScrollingFrame", frameProps, { }) ``` -> **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 +## ProductItem 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 @@ -296,7 +298,7 @@ local shopPage = Roact.createElement(ShopPage, { }, }, frameProps = { - -- here we define the properties of our ShopPage scrolling frame + -- here we define the properties of the ScrollingFrame for our ShopPage AnchorPoint = Vector2.new(0.5, 0.5), BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 0.5, 0), @@ -323,6 +325,9 @@ end Then, we simply use `Roact.Event.Activated` to tell Roact to connect the function to the _Activated_ event. ```lua +function ProductItem:render() + ... + return Roact.createElement("ImageButton", { BackgroundTransparency = 1, Image = "", @@ -331,8 +336,11 @@ return Roact.createElement("ImageButton", { }, { ... } + + ... +end ``` -> **Why is there brackets `[]` here?** +> **Why are 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"]`. > @@ -340,7 +348,7 @@ return Roact.createElement("ImageButton", { ## 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. +To make our UI more lively, we are going to use [*TweenService*](https://developer.roblox.com/en-us/api-reference/class/TweenService) for animations. 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() @@ -362,7 +370,7 @@ Icon = Roact.createElement("ImageLabel", { }), ``` -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. +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 is created. ```lua function ProductItem:didMount() @@ -378,7 +386,7 @@ function ProductItem:didMount() 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. +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 do this. ```lua function ProductItem:willUnmount() @@ -390,7 +398,7 @@ 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) +Here explain how the component can be used my another component or how it can be mounted on its 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. From 5a833fb54bf1a37504cd1456eea1116e59fc162f Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Mon, 29 Jul 2019 13:14:12 -0700 Subject: [PATCH 14/16] Add tutorial content Rename ProductItem to Item and ProductItemList to ItemList Fix aspect ratio --- docs/tutorials/shop-page.md | 117 +++++++++++++----- .../Components/{ProductItem.lua => Item.lua} | 12 +- .../{ProductItemList.lua => ItemList.lua} | 8 +- examples/shop-page/Components/ShopPage.lua | 10 +- 4 files changed, 101 insertions(+), 46 deletions(-) rename examples/shop-page/Components/{ProductItem.lua => Item.lua} (90%) rename examples/shop-page/Components/{ProductItemList.lua => ItemList.lua} (67%) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index 8b240d8c..a814c90f 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -48,12 +48,13 @@ As you can see, `frameProps` is used when creating the ScrollingFrame! This way, ### 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 let us view our component. This script needs to _mount_ our ShopPage into a parent, which will simply be a ScreenGui. +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 let us view our component. This script needs to mount our ShopPage into a parent, which will simply be a ScreenGui. ```lua local ReplicatedStorage = game:GetService("ReplicatedStorage") local Players = game:GetService("Players") +-- we assume here that the components are located in a folder under ReplicatedStorage local Components = ReplicatedStorage:WaitForChild("Components") local Roact = require(ReplicatedStorage.Roact) @@ -65,6 +66,7 @@ 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 + padding = 10, frameProps = { -- here we define the properties of the ScrollingFrame for our ShopPage AnchorPoint = Vector2.new(0.5, 0.5), @@ -72,7 +74,6 @@ local shopPage = Roact.createElement(ShopPage, { Position = UDim2.new(0.5, 0, 0.5, 0), Size = UDim2.new(0.5, 0, 0.5, 0), }, - padding = 10, }) Roact.mount(shopPage, screenGui) @@ -88,6 +89,25 @@ We want to adjust the ScrollingFrame so its *CanvasSize* property always fits th - **itemsPerRow:** the number of items on each row - **itemAspectRatio:** the aspect ratio of each item +We can pass those props down from the script that mounts the ShopPage component. We can edit the previous script to add those props. + +```lua +local shopPage = Roact.createElement(ShopPage, { + items = {}, + -- add the new props with any value you would like + itemAspectRatio = 1, + itemsPerRow = 3, + -- + padding = 10, + frameProps = { + 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), + }, +}) +``` + 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 ScrollingFrame's *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 @@ -149,7 +169,7 @@ function ShopPage:init() local totalWidth = frame.AbsoluteSize.X local cellWidth = (totalWidth - padding * (itemsPerRow + 1)) / itemsPerRow - local cellHeight = cellWidth * props.itemAspectRatio + local cellHeight = cellWidth / props.itemAspectRatio local rows = math.ceil(#props.items / itemsPerRow) local canvasHeight = rows * cellHeight + padding * (rows + 1) @@ -169,19 +189,31 @@ function ShopPage:render() end ``` +> Aspect Ratio +> +> Aspect ratio is a number that represent the relation between the width and the height of an image. It can easily be calculated by dividing the width by the height of the image. In the previous snippet, we are using the given aspect ratio to calculate the space needed to fit all the items. We start by calculating the width of a cell by taking the width of the scrolling frame, removing the padding between all the items and then divide it by the number of items on a row. +> ```lua +> availableWidth = totalWidth - padding * (itemsPerRow + 1) +> cellWidth = availableWidth / itemsPerRow +> ``` +> So now in order to maintain the aspect ratio, we can calculate the height of the cell by dividing the width by the aspect ratio. +> ```lua +> cellHeight = cellWidth / aspectRatio +> ``` + ## Adding Items In the previous section we created a ScrollingFrame 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 prompts the player to buy the product - - **ProductItemList:** iterate on the list of items to create each individual ProductItem element + - **Item:** a card that shows the price of an item with its image, and when clicked prompts the player to buy the product + - **ItemList:** iterate on the list of items to create each individual Item element -### ProductItemList +### ItemList -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 made our ProductItem component yet, but we will pretend it is there for now. +This component will simply be given the product list and create all the Item elements. Since it does not need any internal state, a functional component will do the job. We have not made our Item component yet, but we will pretend it is there for now. -We will define the props that the ProductItem will use to render its component. We will make the ProductItem component accept these props: +We will define the props that the Item component will use to render its component. We will make it accepts these props: - **image:** the image representing the product that the component will display. - **price:** how many robux the product costs. - **productId:** the identifier of the developer product. @@ -194,9 +226,9 @@ local Components = ReplicatedStorage:WaitForChild("Components") local Roact = require(ReplicatedStorage.Roact) -local ProductItem = require(Components:WaitForChild("ProductItem")) +local Item = require(Components:WaitForChild("Item")) -local function ProductItemList(props) +local function ItemList(props) local items = props.items local elements = {} @@ -204,7 +236,7 @@ local function ProductItemList(props) for i=1, #items do local item = items[i] - elements[item.identifier] = Roact.createElement(ProductItem, { + elements[item.identifier] = Roact.createElement(Item, { image = item.image, price = item.price, productId = item.productId, @@ -215,14 +247,14 @@ local function ProductItemList(props) return Roact.createFragment(elements) end -return ProductItemList +return ItemList ``` > **Fragments** > -> Elements contained in a Fragment are parented to the Fragment's parent. That way responsibilities of the grid view can be split from the content. The ShopPage component will handle the grid view, so it owns the *UIGridLayout* object, and it will give the responsibility to the *ProductItemList* component to take care of the content. +> Elements contained in a Fragment are parented to the Fragment's parent. That way responsibilities of the grid view can be split from the content. The ShopPage component will handle the grid view, so it owns the *UIGridLayout* object, and it will give the responsibility to the *ItemList* component to take care of the content. -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. +With that, we can go change our render function in the ShopPage component to create an element of ItemList and pass the items list as a prop. ```lua return Roact.createElement("ScrollingFrame", frameProps, { @@ -233,22 +265,22 @@ return Roact.createElement("ScrollingFrame", frameProps, { VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder, }), - ProductItems = Roact.createElement(ProductItemList, { - items = items + Items = Roact.createElement(ItemList, { + items = items, }), }) ``` -## ProductItem +## 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") +local Item = Roact.Component:extend("Item") -function ProductItem:render() +function Item:render() local props = self.props local image = props.image @@ -282,10 +314,10 @@ function ProductItem:render() }) end -return ProductItem +return Item ``` -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. +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. Don't forget to customize the settings here: put an image that fits your product, enter the price and the developer product ID. ```lua local shopPage = Roact.createElement(ShopPage, { @@ -294,6 +326,7 @@ local shopPage = Roact.createElement(ShopPage, { identifier = "Red Visor", image = "https://www.roblox.com/asset-thumbnail/image?assetId=139618072&width=420&height=420&format=png", price = 30, + -- here we should have a valid developer product id productId = 0, }, }, @@ -313,7 +346,7 @@ Now, we want to prompt the player to buy the product when he clicks on the butto ```lua local MarketplaceService = game:GetService("MarketplaceService") -function ProductItem:init() +function Item:init() self.onActivated = function() local props = self.props @@ -325,7 +358,7 @@ end Then, we simply use `Roact.Event.Activated` to tell Roact to connect the function to the _Activated_ event. ```lua -function ProductItem:render() +function Item:render() ... return Roact.createElement("ImageButton", { @@ -351,7 +384,7 @@ end To make our UI more lively, we are going to use [*TweenService*](https://developer.roblox.com/en-us/api-reference/class/TweenService) for animations. 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() +function Item:init() self.ref = Roact.createRef() ... end @@ -373,7 +406,7 @@ Icon = Roact.createElement("ImageLabel", { 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 is created. ```lua -function ProductItem:didMount() +function Item:didMount() local tweenInfo = TweenInfo.new(0.2) local icon = self.ref:getValue() @@ -389,16 +422,38 @@ 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 do this. ```lua -function ProductItem:willUnmount() +function Item:willUnmount() self.toBigIcon:Destroy() self.toNormalIcon:Destroy() end ``` -## Integration +## Integration in a non-Roact project +If your game already already has UI elements, you don't have to redo them in Roact in order to integrate this ShopPage component into your game. Roact can be used in an existing UI tree. For example, let's say you would already have a ScreenGui with a Frame inside where you'd would like to put this component. In a LocalScript, you would simply have to mount the ShopPage component in the Frame. + +```lua +local Players = game:GetService("Players") +local LocalPlayer = Players.LocalPlayer + +local playerGui = LocalPlayer:WaitForChild("PlayerGui") +local screenGui = playerGui:WaitForChild("MyScreenGui") +local frame = screenGui:WaitForChild("TheShopPageContainer") -### In a Roact project -Here explain how the component can be used my another component or how it can be mounted on its own (like main.client.lua does) +local shopPage = Roact.createElement(ShopPage, { + items = {...}, -- Put your items here + itemAspectRatio = 1, + itemsPerRow = 3, + padding = 10, + frameProps = { + -- Customize the scrolling frame here. For example, we can make this + -- one transparent and fill up the container + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 0), + Size = UDim2.new(1, 0, 1, 0), + }, +}) + +Roact.mount(shopPage, frame) +``` -### 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. +As you can see from this, it is possible to slowly port an existing UI to use Roact by mounting components into existing instances of the UI. diff --git a/examples/shop-page/Components/ProductItem.lua b/examples/shop-page/Components/Item.lua similarity index 90% rename from examples/shop-page/Components/ProductItem.lua rename to examples/shop-page/Components/Item.lua index bbadf32a..6070d82b 100644 --- a/examples/shop-page/Components/ProductItem.lua +++ b/examples/shop-page/Components/Item.lua @@ -5,11 +5,11 @@ local TweenService = game:GetService("TweenService") local Roact = require(ReplicatedStorage.Roact) -local ProductItem = Roact.Component:extend("ProductItem") +local Item = Roact.Component:extend("Item") local PADDING = 20 -function ProductItem:init() +function Item:init() self.ref = Roact.createRef() self.onMouseEnter = function() self.toBigIcon:Play() @@ -24,7 +24,7 @@ function ProductItem:init() end end -function ProductItem:render() +function Item:render() local props = self.props local image = props.image @@ -62,7 +62,7 @@ function ProductItem:render() }) end -function ProductItem:didMount() +function Item:didMount() local tweenInfo = TweenInfo.new(0.2) local icon = self.ref:getValue() @@ -74,9 +74,9 @@ function ProductItem:didMount() }) end -function ProductItem:willUnmount() +function Item:willUnmount() self.toBigIcon:Destroy() self.toNormalIcon:Destroy() end -return ProductItem \ No newline at end of file +return Item \ No newline at end of file diff --git a/examples/shop-page/Components/ProductItemList.lua b/examples/shop-page/Components/ItemList.lua similarity index 67% rename from examples/shop-page/Components/ProductItemList.lua rename to examples/shop-page/Components/ItemList.lua index dfa517fe..d94ca234 100644 --- a/examples/shop-page/Components/ProductItemList.lua +++ b/examples/shop-page/Components/ItemList.lua @@ -4,9 +4,9 @@ local Components = script.Parent local Roact = require(ReplicatedStorage.Roact) -local ProductItem = require(Components:WaitForChild("ProductItem")) +local Item = require(Components:WaitForChild("Item")) -local function ProductItemList(props) +local function ItemList(props) local items = props.items local elements = {} @@ -14,7 +14,7 @@ local function ProductItemList(props) for i=1, #items do local item = items[i] - elements[item.identifier] = Roact.createElement(ProductItem, { + elements[item.identifier] = Roact.createElement(Item, { image = item.image, price = item.price, productId = item.productId, @@ -25,4 +25,4 @@ local function ProductItemList(props) return Roact.createFragment(elements) end -return ProductItemList \ No newline at end of file +return ItemList \ No newline at end of file diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index 08e5eb04..33681b60 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -3,7 +3,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Components = script.Parent local Roact = require(ReplicatedStorage.Roact) -local ProductItemList = require(Components:WaitForChild("ProductItemList")) +local ItemList = require(Components:WaitForChild("ItemList")) local ShopPage = Roact.Component:extend("ShopPage") @@ -24,7 +24,7 @@ function ShopPage:init() local totalWidth = frame.AbsoluteSize.X local cellWidth = (totalWidth - padding * (itemsPerRow + 1)) / itemsPerRow - local cellHeight = cellWidth * props.itemAspectRatio + local cellHeight = cellWidth / props.itemAspectRatio local rows = math.ceil(#props.items / itemsPerRow) local canvasHeight = rows * cellHeight + padding * (rows + 1) @@ -59,13 +59,13 @@ function ShopPage:render() return Roact.createElement("ScrollingFrame", newFrameProps, { UIGrid = Roact.createElement("UIGridLayout", { CellPadding = UDim2.new(0, padding, 0, padding), - CellSize = UDim2.new(0, cellSize, 0, cellSize * itemAspectRatio), + CellSize = UDim2.new(0, cellSize, 0, cellSize / itemAspectRatio), HorizontalAlignment = Enum.HorizontalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder, }), - ProductItems = Roact.createElement(ProductItemList, { - items = items + Items = Roact.createElement(ItemList, { + items = items, }), }) end From f889eff58cbcf583b203cb7d166853757328a43b Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Mon, 29 Jul 2019 16:08:00 -0700 Subject: [PATCH 15/16] Change cellSize and height default value --- docs/tutorials/shop-page.md | 4 ++-- examples/shop-page/Components/ShopPage.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/shop-page.md b/docs/tutorials/shop-page.md index a814c90f..50350523 100644 --- a/docs/tutorials/shop-page.md +++ b/docs/tutorials/shop-page.md @@ -114,8 +114,8 @@ With these new props, we are going to be able to compute the optimal cell size f function ShopPage:init() -- set default values self:setState({ - cellSize = 100, - canvasHeight = 100, + cellSize = 0, + canvasHeight = 0, }) end ``` diff --git a/examples/shop-page/Components/ShopPage.lua b/examples/shop-page/Components/ShopPage.lua index 33681b60..caa41a5a 100644 --- a/examples/shop-page/Components/ShopPage.lua +++ b/examples/shop-page/Components/ShopPage.lua @@ -13,8 +13,8 @@ ShopPage.defaultProps = { function ShopPage:init() self:setState({ - cellSize = 100, - canvasHeight = 100, + cellSize = 0, + canvasHeight = 0, }) self.onAbsoluteSizeChanged = function(frame) From 0f908e6a59b50a0d91db1ea88124656369240cda Mon Sep 17 00:00:00 2001 From: Olivier Trepanier Date: Wed, 14 Aug 2019 14:34:14 -0700 Subject: [PATCH 16/16] Fix site rendering issues and add preview image --- docs/images/shop-page.jpg | Bin 0 -> 67089 bytes docs/tutorials/shop-page.md | 127 +++++++++++++++++++++++------------- examples/shop-page/init.lua | 1 + 3 files changed, 82 insertions(+), 46 deletions(-) create mode 100644 docs/images/shop-page.jpg diff --git a/docs/images/shop-page.jpg b/docs/images/shop-page.jpg new file mode 100644 index 0000000000000000000000000000000000000000..48b2e4175e00147ea567c9146b566601b55cf2a2 GIT binary patch literal 67089 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ℜBoKa;R1pI2 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 + + Shop Page Preview + + -## The ShopPage component +--- + +## The ShopPage Component Let's start by creating the top level component, which will be the page that contains the items for sale in the game. When creating a new component, we need to think about what this component will manage and what will be given to that component from the parent component. In our case, the ShopPage component we're making will receive its 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 with 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 its child are aware of the structure of the props dictionary and depend on it. +!!! Note +

Props or state?
+ This is a question that often comes to our mind when we start working with 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 its child are aware of the structure of the props dictionary and depend 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 ScrollingFrame to change its visuals - - **items:** a list of objects that represent the products we want to sell - - **padding:** the number of pixels between each of our items in the shop + + - **frameProps:** a dictionary of props that will be passed to the ScrollingFrame to change its visuals + - **items:** a list of objects that represent the products we want to sell + - **padding:** the number of pixels between each of our items in the shop ```lua local ShopPage = Roact.Component:extend("ShopPage") @@ -46,7 +53,9 @@ return ShopPage As you can see, `frameProps` is used when creating the ScrollingFrame! This way, we can control how the container looks from outside the parent of the component. -### Viewing 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 let us view our component. This script needs to mount our ShopPage into a parent, which will simply be a ScreenGui. @@ -54,7 +63,8 @@ Now that we have started working on our first component, we want to be able to s local ReplicatedStorage = game:GetService("ReplicatedStorage") local Players = game:GetService("Players") --- we assume here that the components are located in a folder under ReplicatedStorage +-- we assume here that the components are located in +-- a folder under ReplicatedStorage local Components = ReplicatedStorage:WaitForChild("Components") local Roact = require(ReplicatedStorage.Roact) @@ -65,11 +75,15 @@ 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 + -- we're not ready for this yet so it's just + -- an empty list + items = {}, padding = 10, frameProps = { - -- here we define the properties of the ScrollingFrame for our ShopPage + -- here we define the properties of the ScrollingFrame + -- for our ShopPage AnchorPoint = Vector2.new(0.5, 0.5), + BackgroundColor3 = Color3.fromRGB(0, 3, 20), BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 0.5, 0), Size = UDim2.new(0.5, 0, 0.5, 0), @@ -80,14 +94,18 @@ 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 GUI instances we need to see the results + + - An element is created from our ShopPage component, where we specified the different props we want + - `Roact.mount` will create the real GUI instances we need to see the results + +--- ### Adjusting Canvas Size from Items We want to adjust the ScrollingFrame so its *CanvasSize* property always fits 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 + + - **itemsPerRow:** the number of items on each row + - **itemAspectRatio:** the aspect ratio of each item We can pass those props down from the script that mounts the ShopPage component. We can edit the previous script to add those props. @@ -101,6 +119,7 @@ local shopPage = Roact.createElement(ShopPage, { padding = 10, frameProps = { AnchorPoint = Vector2.new(0.5, 0.5), + BackgroundColor3 = Color3.fromRGB(0, 3, 20), BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 0.5, 0), Size = UDim2.new(0.5, 0, 0.5, 0), @@ -189,35 +208,44 @@ function ShopPage:render() end ``` -> Aspect Ratio -> -> Aspect ratio is a number that represent the relation between the width and the height of an image. It can easily be calculated by dividing the width by the height of the image. In the previous snippet, we are using the given aspect ratio to calculate the space needed to fit all the items. We start by calculating the width of a cell by taking the width of the scrolling frame, removing the padding between all the items and then divide it by the number of items on a row. -> ```lua -> availableWidth = totalWidth - padding * (itemsPerRow + 1) -> cellWidth = availableWidth / itemsPerRow -> ``` -> So now in order to maintain the aspect ratio, we can calculate the height of the cell by dividing the width by the aspect ratio. -> ```lua -> cellHeight = cellWidth / aspectRatio -> ``` +!!! Note +
Aspect Ratio
+ Aspect ratio is a number that represent the relation between the width and the height of an image. It can easily be calculated by dividing the width by the height of the image. In the previous snippet, we are using the given aspect ratio to calculate the space needed to fit all the items. We start by calculating the width of a cell by taking the width of the scrolling frame, removing the padding between all the items and then divide it by the number of items on a row. + + ```lua + availableWidth = totalWidth - padding * (itemsPerRow + 1) + cellWidth = availableWidth / itemsPerRow + ``` + + So now in order to maintain the aspect ratio, we can calculate the height of the cell by dividing the width by the aspect ratio. + + ```lua + cellHeight = cellWidth / aspectRatio + ``` + +--- ## Adding Items In the previous section we created a ScrollingFrame 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: - - **Item:** a card that shows the price of an item with its image, and when clicked prompts the player to buy the product - - **ItemList:** iterate on the list of items to create each individual Item element + + - **Item:** a card that shows the price of an item with its image, and when clicked prompts the player to buy the product + - **ItemList:** iterate on the list of items to create each individual Item element + +--- ### ItemList This component will simply be given the product list and create all the Item elements. Since it does not need any internal state, a functional component will do the job. We have not made our Item component yet, but we will pretend it is there for now. We will define the props that the Item component will use to render its component. We will make it accepts these props: -- **image:** the image representing the product that the component will display. -- **price:** how many robux the product costs. -- **productId:** the identifier of the developer product. -- **order:** used 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. + + - **image:** the image representing the product that the component will display. + - **price:** how many robux the product costs. + - **productId:** the identifier of the developer product. + - **order:** used 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") @@ -250,9 +278,9 @@ end return ItemList ``` -> **Fragments** -> -> Elements contained in a Fragment are parented to the Fragment's parent. That way responsibilities of the grid view can be split from the content. The ShopPage component will handle the grid view, so it owns the *UIGridLayout* object, and it will give the responsibility to the *ItemList* component to take care of the content. +!!! Note +
Fragments
+ Elements contained in a Fragment are parented to the Fragment's parent. That way responsibilities of the grid view can be split from the content. The ShopPage component will handle the grid view, so it owns the *UIGridLayout* object, and it will give the responsibility to the *ItemList* component to take care of the content. With that, we can go change our render function in the ShopPage component to create an element of ItemList and pass the items list as a prop. @@ -271,6 +299,7 @@ return Roact.createElement("ScrollingFrame", frameProps, { }) ``` +--- ## 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. @@ -333,6 +362,7 @@ local shopPage = Roact.createElement(ShopPage, { frameProps = { -- here we define the properties of the ScrollingFrame for our ShopPage AnchorPoint = Vector2.new(0.5, 0.5), + BackgroundColor3 = Color3.fromRGB(0, 3, 20), BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 0.5, 0), Size = UDim2.new(0.5, 0, 0.5, 0), @@ -373,11 +403,14 @@ return Roact.createElement("ImageButton", { ... end ``` -> **Why are 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. + +!!! Note +
Why are 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 @@ -428,8 +461,10 @@ function Item:willUnmount() end ``` +--- + ## Integration in a non-Roact project -If your game already already has UI elements, you don't have to redo them in Roact in order to integrate this ShopPage component into your game. Roact can be used in an existing UI tree. For example, let's say you would already have a ScreenGui with a Frame inside where you'd would like to put this component. In a LocalScript, you would simply have to mount the ShopPage component in the Frame. +If your game already has UI elements, you don't have to redo them in Roact in order to integrate this ShopPage component into your game. Roact can be used in an existing UI tree. For example, let's say you would have a ScreenGui with a Frame inside where you'd would like to put this component. In a LocalScript, you would simply have to mount the ShopPage component in the Frame. ```lua local Players = game:GetService("Players") @@ -445,8 +480,8 @@ local shopPage = Roact.createElement(ShopPage, { itemsPerRow = 3, padding = 10, frameProps = { - -- Customize the scrolling frame here. For example, we can make this - -- one transparent and fill up the container + -- Customize the scrolling frame here. For example, we can + -- make this one transparent and fill up the container BackgroundTransparency = 1, Position = UDim2.new(0, 0, 0, 0), Size = UDim2.new(1, 0, 1, 0), diff --git a/examples/shop-page/init.lua b/examples/shop-page/init.lua index 1324b026..9eb9444a 100644 --- a/examples/shop-page/init.lua +++ b/examples/shop-page/init.lua @@ -19,6 +19,7 @@ return function() padding = 10, frameProps = { AnchorPoint = Vector2.new(0.5, 0.5), + BackgroundColor3 = Color3.fromRGB(0, 3, 20), BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 0.5, 0), Size = UDim2.new(0.5, 0, 0.5, 0),