← Volver al listado de tecnologías

UI Responsive y Adaptación a Diferentes Pantallas

Por: Artiko
defolduiresponsivemobilepantallasgui

UI Responsive y Adaptación a Diferentes Pantallas

Crear interfaces que se adapten perfectamente a cualquier dispositivo móvil es fundamental para el éxito de tu juego. En esta guía aprenderás a implementar UI responsive en Defold.

Conceptos Fundamentales

Resoluciones y Densidades de Pantalla

Resoluciones Comunes

-- screen_resolutions.lua
local M = {}

M.COMMON_RESOLUTIONS = {
    -- iPhone
    {name = "iPhone 5", width = 320, height = 568, ratio = 16/9},
    {name = "iPhone 6/7/8", width = 375, height = 667, ratio = 16/9},
    {name = "iPhone 6+/7+/8+", width = 414, height = 736, ratio = 16/9},
    {name = "iPhone X/XS", width = 375, height = 812, ratio = 19.5/9},
    {name = "iPhone XR", width = 414, height = 896, ratio = 19.5/9},
    {name = "iPhone 12/13/14", width = 390, height = 844, ratio = 19.5/9},
    {name = "iPhone 12/13/14 Pro Max", width = 428, height = 926, ratio = 19.5/9},

    -- Android
    {name = "Android Small", width = 320, height = 480, ratio = 3/2},
    {name = "Android Medium", width = 360, height = 640, ratio = 16/9},
    {name = "Android Large", width = 411, height = 731, ratio = 16/9},
    {name = "Android XL", width = 412, height = 892, ratio = 18.5/9},

    -- Tablets
    {name = "iPad", width = 768, height = 1024, ratio = 4/3},
    {name = "iPad Pro", width = 834, height = 1194, ratio = 4/3},
    {name = "Android Tablet", width = 800, height = 1280, ratio = 16/10}
}

function M.get_device_info()
    local width = tonumber(sys.get_config("display.width"))
    local height = tonumber(sys.get_config("display.height"))
    local ratio = width / height

    return {
        width = width,
        height = height,
        ratio = ratio,
        is_tablet = width > 600 or height > 600
    }
end

return M

Sistema de Anchors y Pivots

Configuración de Anchors

-- anchor_system.script
local M = {}

-- Tipos de anchor predefinidos
M.ANCHOR_TYPES = {
    TOP_LEFT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_NW},
    TOP_CENTER = {anchor = gui.ANCHOR_TOP, pivot = gui.PIVOT_N},
    TOP_RIGHT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_NE},

    CENTER_LEFT = {anchor = gui.ANCHOR_LEFT, pivot = gui.PIVOT_W},
    CENTER = {anchor = gui.ANCHOR_CENTER, pivot = gui.PIVOT_CENTER},
    CENTER_RIGHT = {anchor = gui.ANCHOR_RIGHT, pivot = gui.PIVOT_E},

    BOTTOM_LEFT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_SW},
    BOTTOM_CENTER = {anchor = gui.ANCHOR_BOTTOM, pivot = gui.PIVOT_S},
    BOTTOM_RIGHT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_SE}
}

function M.set_anchor(node, anchor_type)
    local config = M.ANCHOR_TYPES[anchor_type]
    if config then
        gui.set_anchor(node, config.anchor)
        gui.set_pivot(node, config.pivot)
    end
end

function M.position_at_anchor(node, anchor_type, offset_x, offset_y)
    M.set_anchor(node, anchor_type)

    local screen_width = tonumber(sys.get_config("display.width"))
    local screen_height = tonumber(sys.get_config("display.height"))

    local x, y = 0, 0

    if anchor_type == "TOP_LEFT" then
        x, y = offset_x, screen_height - offset_y
    elseif anchor_type == "TOP_RIGHT" then
        x, y = screen_width - offset_x, screen_height - offset_y
    elseif anchor_type == "BOTTOM_LEFT" then
        x, y = offset_x, offset_y
    elseif anchor_type == "BOTTOM_RIGHT" then
        x, y = screen_width - offset_x, offset_y
    end

    gui.set_position(node, vmath.vector3(x, y, 0))
end

return M

Layout Responsivo

1. Sistema de Grid Flexible

Grid Layout Manager

-- grid_layout.lua
local M = {}

function M.create_grid(parent_node, rows, cols, padding, spacing)
    local parent_size = gui.get_size(parent_node)
    local available_width = parent_size.x - (padding.left + padding.right) - (spacing.x * (cols - 1))
    local available_height = parent_size.y - (padding.top + padding.bottom) - (spacing.y * (rows - 1))

    local cell_width = available_width / cols
    local cell_height = available_height / rows

    local grid = {
        parent = parent_node,
        rows = rows,
        cols = cols,
        cell_width = cell_width,
        cell_height = cell_height,
        padding = padding,
        spacing = spacing,
        cells = {}
    }

    return grid
end

function M.add_to_grid(grid, node, row, col, span_rows, span_cols)
    span_rows = span_rows or 1
    span_cols = span_cols or 1

    local start_x = grid.padding.left + (col - 1) * (grid.cell_width + grid.spacing.x)
    local start_y = grid.padding.top + (row - 1) * (grid.cell_height + grid.spacing.y)

    local width = grid.cell_width * span_cols + grid.spacing.x * (span_cols - 1)
    local height = grid.cell_height * span_rows + grid.spacing.y * (span_rows - 1)

    gui.set_position(node, vmath.vector3(start_x, start_y, 0))
    gui.set_size(node, vmath.vector3(width, height, 0))

    grid.cells[row .. "_" .. col] = {
        node = node,
        row = row,
        col = col,
        span_rows = span_rows,
        span_cols = span_cols
    }
end

function M.update_grid(grid)
    local parent_size = gui.get_size(grid.parent)
    local available_width = parent_size.x - (grid.padding.left + grid.padding.right) - (grid.spacing.x * (grid.cols - 1))
    local available_height = parent_size.y - (grid.padding.top + grid.padding.bottom) - (grid.spacing.y * (grid.rows - 1))

    grid.cell_width = available_width / grid.cols
    grid.cell_height = available_height / grid.rows

    -- Reposicionar todos los elementos
    for key, cell in pairs(grid.cells) do
        M.add_to_grid(grid, cell.node, cell.row, cell.col, cell.span_rows, cell.span_cols)
    end
end

return M

2. Safe Area y Notch Support

Safe Area Manager

-- safe_area.script
local M = {}

function M.get_safe_area()
    local screen_width = tonumber(sys.get_config("display.width"))
    local screen_height = tonumber(sys.get_config("display.height"))

    -- Valores por defecto
    local safe_area = {
        top = 0,
        bottom = 0,
        left = 0,
        right = 0
    }

    -- Detectar iPhone X y modelos posteriores
    local sys_info = sys.get_sys_info()
    if sys_info.system_name == "iPhone OS" then
        local device_model = sys_info.device_model

        -- iPhone X, XS, XR, 11, 12, 13, 14 series
        if string.find(device_model, "iPhone1[0-9],") or
           string.find(device_model, "iPhone11,") or
           string.find(device_model, "iPhone12,") or
           string.find(device_model, "iPhone13,") or
           string.find(device_model, "iPhone14,") then

            if screen_height > screen_width then
                -- Portrait
                safe_area.top = 44
                safe_area.bottom = 34
            else
                -- Landscape
                safe_area.left = 44
                safe_area.right = 44
            end
        end
    end

    return safe_area
end

function M.apply_safe_area_to_node(node, margin_top, margin_bottom, margin_left, margin_right)
    local safe_area = M.get_safe_area()
    local screen_width = tonumber(sys.get_config("display.width"))
    local screen_height = tonumber(sys.get_config("display.height"))

    margin_top = margin_top or 0
    margin_bottom = margin_bottom or 0
    margin_left = margin_left or 0
    margin_right = margin_right or 0

    local new_width = screen_width - safe_area.left - safe_area.right - margin_left - margin_right
    local new_height = screen_height - safe_area.top - safe_area.bottom - margin_top - margin_bottom
    local new_x = safe_area.left + margin_left
    local new_y = safe_area.bottom + margin_bottom

    gui.set_position(node, vmath.vector3(new_x, new_y, 0))
    gui.set_size(node, vmath.vector3(new_width, new_height, 0))
end

return M

Adaptación por Orientación

1. Detector de Orientación

Orientation Manager

-- orientation_manager.script
local M = {}

M.ORIENTATION = {
    PORTRAIT = "portrait",
    LANDSCAPE = "landscape",
    PORTRAIT_UPSIDE_DOWN = "portrait_upside_down",
    LANDSCAPE_LEFT = "landscape_left",
    LANDSCAPE_RIGHT = "landscape_right"
}

function init(self)
    self.current_orientation = M.get_orientation()
    self.orientation_callbacks = {}

    -- Suscribirse a cambios de ventana
    window.set_listener(function(self, event, data)
        if event == window.WINDOW_EVENT_RESIZED then
            M.check_orientation_change(self)
        end
    end)
end

function M.get_orientation()
    local width = tonumber(sys.get_config("display.width"))
    local height = tonumber(sys.get_config("display.height"))

    if height > width then
        return M.ORIENTATION.PORTRAIT
    else
        return M.ORIENTATION.LANDSCAPE
    end
end

function M.check_orientation_change(self)
    local new_orientation = M.get_orientation()

    if new_orientation ~= self.current_orientation then
        local old_orientation = self.current_orientation
        self.current_orientation = new_orientation

        -- Notificar a todos los callbacks registrados
        for _, callback in ipairs(self.orientation_callbacks) do
            callback(new_orientation, old_orientation)
        end

        msg.post(".", "orientation_changed", {
            new_orientation = new_orientation,
            old_orientation = old_orientation
        })
    end
end

function M.register_callback(callback)
    table.insert(self.orientation_callbacks, callback)
end

function M.is_portrait()
    return M.get_orientation() == M.ORIENTATION.PORTRAIT
end

function M.is_landscape()
    return M.get_orientation() == M.ORIENTATION.LANDSCAPE
end

return M

2. Layout Adaptive por Orientación

Adaptive Layout

-- adaptive_layout.gui_script
local orientation_manager = require "main.orientation_manager"
local grid_layout = require "main.grid_layout"

function init(self)
    self.portrait_layout = {}
    self.landscape_layout = {}

    -- Configurar layouts por orientación
    self:setup_portrait_layout()
    self:setup_landscape_layout()

    -- Aplicar layout inicial
    self:apply_current_layout()

    -- Registrar callback para cambios de orientación
    orientation_manager.register_callback(function(new_orientation, old_orientation)
        self:apply_current_layout()
    end)
end

function setup_portrait_layout(self)
    -- Layout vertical para portrait
    self.portrait_layout = {
        header = {
            anchor = "TOP_CENTER",
            size = {width = "100%", height = 120},
            position = {x = 0, y = -20}
        },
        main_content = {
            anchor = "CENTER",
            size = {width = "90%", height = "60%"},
            position = {x = 0, y = -40}
        },
        bottom_bar = {
            anchor = "BOTTOM_CENTER",
            size = {width = "100%", height = 100},
            position = {x = 0, y = 20}
        }
    }
end

function setup_landscape_layout(self)
    -- Layout horizontal para landscape
    self.landscape_layout = {
        header = {
            anchor = "TOP_CENTER",
            size = {width = "100%", height = 80},
            position = {x = 0, y = -10}
        },
        main_content = {
            anchor = "CENTER",
            size = {width = "70%", height = "70%"},
            position = {x = 0, y = 0}
        },
        side_panel = {
            anchor = "CENTER_RIGHT",
            size = {width = "25%", height = "70%"},
            position = {x = -20, y = 0}
        },
        bottom_bar = {
            anchor = "BOTTOM_CENTER",
            size = {width = "100%", height = 60},
            position = {x = 0, y = 10}
        }
    }
end

function apply_current_layout(self)
    local layout = orientation_manager.is_portrait() and self.portrait_layout or self.landscape_layout

    for element_name, config in pairs(layout) do
        local node = gui.get_node(element_name)
        if node then
            self:apply_layout_config(node, config)
        end
    end

    -- Animación suave de transición
    gui.animate(gui.get_node("root"), gui.PROP_COLOR, vmath.vector4(1,1,1,1), gui.EASING_OUTQUAD, 0.3)
end

function apply_layout_config(self, node, config)
    local screen_width = tonumber(sys.get_config("display.width"))
    local screen_height = tonumber(sys.get_config("display.height"))

    -- Calcular tamaño
    local width = config.size.width
    local height = config.size.height

    if type(width) == "string" and string.find(width, "%%") then
        width = screen_width * (tonumber(string.sub(width, 1, -2)) / 100)
    end

    if type(height) == "string" and string.find(height, "%%") then
        height = screen_height * (tonumber(string.sub(height, 1, -2)) / 100)
    end

    gui.set_size(node, vmath.vector3(width, height, 0))

    -- Aplicar anchor y posición
    if config.anchor then
        anchor_system.set_anchor(node, config.anchor)
    end

    if config.position then
        local pos = gui.get_position(node)
        pos.x = pos.x + config.position.x
        pos.y = pos.y + config.position.y
        gui.set_position(node, pos)
    end
end

Escalado de UI

1. Escalado Inteligente

Scale Manager

-- scale_manager.lua
local M = {}

M.SCALE_MODES = {
    FIT = "fit",           -- Ajustar manteniendo proporción
    FILL = "fill",         -- Llenar toda la pantalla
    STRETCH = "stretch",   -- Estirar sin mantener proporción
    LETTERBOX = "letterbox" -- Barras negras para mantener proporción
}

function M.calculate_scale(target_width, target_height, scale_mode)
    local screen_width = tonumber(sys.get_config("display.width"))
    local screen_height = tonumber(sys.get_config("display.height"))

    local scale_x = screen_width / target_width
    local scale_y = screen_height / target_height

    local scale = 1
    local offset_x, offset_y = 0, 0

    if scale_mode == M.SCALE_MODES.FIT then
        scale = math.min(scale_x, scale_y)

    elseif scale_mode == M.SCALE_MODES.FILL then
        scale = math.max(scale_x, scale_y)

    elseif scale_mode == M.SCALE_MODES.STRETCH then
        -- No usar escala uniforme, aplicar diferentes escalas por eje
        return {
            scale_x = scale_x,
            scale_y = scale_y,
            offset_x = 0,
            offset_y = 0
        }

    elseif scale_mode == M.SCALE_MODES.LETTERBOX then
        scale = math.min(scale_x, scale_y)
        offset_x = (screen_width - target_width * scale) / 2
        offset_y = (screen_height - target_height * scale) / 2
    end

    return {
        scale = scale,
        offset_x = offset_x,
        offset_y = offset_y
    }
end

function M.apply_scale_to_node(node, scale_info)
    if scale_info.scale then
        gui.set_scale(node, vmath.vector3(scale_info.scale, scale_info.scale, 1))
    else
        gui.set_scale(node, vmath.vector3(scale_info.scale_x, scale_info.scale_y, 1))
    end

    local pos = gui.get_position(node)
    pos.x = pos.x + scale_info.offset_x
    pos.y = pos.y + scale_info.offset_y
    gui.set_position(node, pos)
end

return M

2. Escalado de Texto Dinámico

Dynamic Text Scaler

-- text_scaler.lua
local M = {}

function M.auto_fit_text(text_node, max_width, max_height, min_font_size, max_font_size)
    min_font_size = min_font_size or 12
    max_font_size = max_font_size or 48

    local text = gui.get_text(text_node)
    local font = gui.get_font(text_node)

    -- Búsqueda binaria para encontrar el tamaño óptimo
    local low = min_font_size
    local high = max_font_size
    local best_size = min_font_size

    while low <= high do
        local mid = math.floor((low + high) / 2)
        gui.set_font_size(text_node, mid)

        local metrics = gui.get_text_metrics(font, text, max_width, true, mid)

        if metrics.width <= max_width and metrics.height <= max_height then
            best_size = mid
            low = mid + 1
        else
            high = mid - 1
        end
    end

    gui.set_font_size(text_node, best_size)
    return best_size
end

function M.scale_text_for_device(text_node, base_size)
    local screen_width = tonumber(sys.get_config("display.width"))
    local base_width = 375 -- iPhone 6/7/8 como referencia

    local scale_factor = screen_width / base_width
    local new_size = math.floor(base_size * scale_factor)

    -- Limitar el rango de tamaños
    new_size = math.max(12, math.min(48, new_size))

    gui.set_font_size(text_node, new_size)
    return new_size
end

return M

Componentes UI Responsivos

1. Botón Responsivo

Responsive Button

-- responsive_button.gui_script
local scale_manager = require "main.scale_manager"
local text_scaler = require "main.text_scaler"

function init(self)
    self.button_node = gui.get_node("button")
    self.label_node = gui.get_node("button_label")
    self.icon_node = gui.get_node("button_icon")

    -- Configuración base
    self.base_size = gui.get_size(self.button_node)
    self.base_font_size = gui.get_font_size(self.label_node)

    self:setup_responsive_behavior()
end

function setup_responsive_behavior(self)
    -- Adaptar según el tamaño de pantalla
    local screen_width = tonumber(sys.get_config("display.width"))
    local device_scale = screen_width / 375 -- Base iPhone 6/7/8

    -- Escalar botón
    local new_width = self.base_size.x * device_scale
    local new_height = self.base_size.y * device_scale
    gui.set_size(self.button_node, vmath.vector3(new_width, new_height, 0))

    -- Escalar texto
    text_scaler.scale_text_for_device(self.label_node, self.base_font_size)

    -- Escalar icono si existe
    if self.icon_node then
        local icon_size = gui.get_size(self.icon_node)
        gui.set_size(self.icon_node, vmath.vector3(
            icon_size.x * device_scale,
            icon_size.y * device_scale,
            0
        ))
    end
end

function on_input(self, action_id, action)
    if action_id == hash("touch") and gui.pick_node(self.button_node, action.x, action.y) then
        if action.pressed then
            -- Efecto visual de presión
            gui.animate(self.button_node, gui.PROP_SCALE, vmath.vector3(0.95, 0.95, 1),
                       gui.EASING_OUTQUAD, 0.1)
        elseif action.released then
            -- Volver al tamaño normal
            gui.animate(self.button_node, gui.PROP_SCALE, vmath.vector3(1, 1, 1),
                       gui.EASING_OUTBACK, 0.2)

            -- Ejecutar acción del botón
            msg.post("main:/controller", "button_pressed", {button_id = "responsive_button"})
        end
        return true
    end
    return false
end

2. Lista Responsiva

Responsive List

-- responsive_list.gui_script
local grid_layout = require "main.grid_layout"

function init(self)
    self.list_container = gui.get_node("list_container")
    self.item_template = gui.get_node("item_template")
    self.items = {}

    -- Ocultar template
    gui.set_enabled(self.item_template, false)

    self:setup_responsive_list()
end

function setup_responsive_list(self)
    local screen_width = tonumber(sys.get_config("display.width"))
    local container_size = gui.get_size(self.list_container)

    -- Calcular cuántos elementos pueden caber por fila
    local item_width = 150
    local spacing = 20
    local cols = math.floor((container_size.x - spacing) / (item_width + spacing))
    cols = math.max(1, cols) -- Mínimo 1 columna

    -- Ajustar el ancho de los elementos para llenar el espacio disponible
    local available_width = container_size.x - (spacing * (cols + 1))
    local adjusted_item_width = available_width / cols

    self.grid_config = {
        cols = cols,
        item_width = adjusted_item_width,
        item_height = 80,
        spacing_x = spacing,
        spacing_y = 20
    }

    print("Responsive list setup: " .. cols .. " columns, item width: " .. adjusted_item_width)
end

function add_item(self, item_data)
    local item_node = gui.clone(self.item_template)
    gui.set_enabled(item_node, true)
    gui.set_parent(item_node, self.list_container)

    -- Configurar contenido del item
    local label_node = gui.get_node("label", item_node)
    if label_node then
        gui.set_text(label_node, item_data.text or "Item")
    end

    -- Calcular posición en grid
    local item_index = #self.items
    local row = math.floor(item_index / self.grid_config.cols)
    local col = item_index % self.grid_config.cols

    local x = self.grid_config.spacing_x + col * (self.grid_config.item_width + self.grid_config.spacing_x)
    local y = -self.grid_config.spacing_y - row * (self.grid_config.item_height + self.grid_config.spacing_y)

    gui.set_position(item_node, vmath.vector3(x, y, 0))
    gui.set_size(item_node, vmath.vector3(self.grid_config.item_width, self.grid_config.item_height, 0))

    table.insert(self.items, {
        node = item_node,
        data = item_data,
        row = row,
        col = col
    })

    return item_node
end

function on_message(self, message_id, message, sender)
    if message_id == hash("orientation_changed") then
        self:setup_responsive_list()
        self:relayout_items()
    end
end

function relayout_items(self)
    for i, item in ipairs(self.items) do
        local row = math.floor((i-1) / self.grid_config.cols)
        local col = (i-1) % self.grid_config.cols

        local x = self.grid_config.spacing_x + col * (self.grid_config.item_width + self.grid_config.spacing_x)
        local y = -self.grid_config.spacing_y - row * (self.grid_config.item_height + self.grid_config.spacing_y)

        gui.animate(item.node, gui.PROP_POSITION, vmath.vector3(x, y, 0), gui.EASING_OUTQUAD, 0.3)
        gui.animate(item.node, gui.PROP_SIZE,
                   vmath.vector3(self.grid_config.item_width, self.grid_config.item_height, 0),
                   gui.EASING_OUTQUAD, 0.3)
    end
end

Testing y Debug

1. Debug de Resoluciones

Resolution Debugger

-- resolution_debugger.gui_script
function init(self)
    self.debug_enabled = sys.get_config("project.debug", "0") == "1"

    if self.debug_enabled then
        self:create_debug_overlay()
    end
end

function create_debug_overlay(self)
    -- Crear overlay de información
    local overlay = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(300, 200, 0))
    gui.set_color(overlay, vmath.vector4(0, 0, 0, 0.7))
    gui.set_anchor(overlay, gui.ANCHOR_TOP)
    gui.set_pivot(overlay, gui.PIVOT_NW)

    -- Texto de información
    local info_text = gui.new_text_node(vmath.vector3(10, -10, 0), "")
    gui.set_parent(info_text, overlay)
    gui.set_anchor(info_text, gui.ANCHOR_NONE)
    gui.set_pivot(info_text, gui.PIVOT_NW)
    gui.set_color(info_text, vmath.vector4(1, 1, 1, 1))

    self.debug_overlay = overlay
    self.debug_text = info_text

    self:update_debug_info()
end

function update_debug_info(self)
    if not self.debug_enabled then return end

    local screen_width = tonumber(sys.get_config("display.width"))
    local screen_height = tonumber(sys.get_config("display.height"))
    local ratio = screen_width / screen_height
    local sys_info = sys.get_sys_info()

    local info = string.format(
        "Resolución: %dx%d\n" ..
        "Ratio: %.2f:1\n" ..
        "Plataforma: %s\n" ..
        "Dispositivo: %s\n" ..
        "DPI: %d\n" ..
        "Orientación: %s",
        screen_width, screen_height,
        ratio,
        sys_info.system_name,
        sys_info.device_model,
        sys_info.dpi or 0,
        orientation_manager.get_orientation()
    )

    gui.set_text(self.debug_text, info)
end

function update(self, dt)
    if self.debug_enabled then
        self:update_debug_info()
    end
end

2. Test de Responsive

Responsive Tester

-- responsive_tester.script
function init(self)
    self.test_resolutions = {
        {name = "iPhone 5", width = 320, height = 568},
        {name = "iPhone 8", width = 375, height = 667},
        {name = "iPhone X", width = 375, height = 812},
        {name = "Pixel 3", width = 393, height = 786},
        {name = "iPad", width = 768, height = 1024}
    }

    self.current_test = 1
end

function test_resolution(self, resolution)
    print("Testing resolution: " .. resolution.name .. " (" .. resolution.width .. "x" .. resolution.height .. ")")

    -- Simular cambio de resolución (solo para testing)
    msg.post("@render:", "change_resolution", {
        width = resolution.width,
        height = resolution.height
    })

    -- Notificar a todos los GUI scripts sobre el cambio
    msg.post("@system:", "test_resolution", resolution)
end

function on_input(self, action_id, action)
    if action.pressed then
        if action_id == hash("test_next") then
            self.current_test = self.current_test + 1
            if self.current_test > #self.test_resolutions then
                self.current_test = 1
            end

            self:test_resolution(self.test_resolutions[self.current_test])
        end
    end
end

Mejores Prácticas

1. Checklist de UI Responsiva

2. Optimizaciones de Rendimiento

3. Testing en Dispositivos Reales

Esta guía te proporciona las herramientas necesarias para crear interfaces verdaderamente responsivas que se adapten perfectamente a cualquier dispositivo móvil.