Conversor de Unidades Offline e Interactivo en JavaFX

Construye un conversor de unidades con múltiples categorías (Longitud, Peso, Temperatura), animaciones dinámicas al cambiar de pestaña y diseño sin bordes.

3 de marzo de 2026 15 min de lectura

Conversor de Unidades Offline e Interactivo en JavaFX

Siguiendo con nuestra serie de interfaces modernas y sin bordes en JavaFX, hoy traemos un proyecto muy práctico: un Conversor de Unidades Offline.

A diferencia de los conversores web que dependen de una API para monedas (cuyos valores fluctúan constantemente), nosotros crearemos una herramienta que soporta medidas físicas y digitales universales. Es decir, funcionará perfectamente sin internet.

Implementaremos las siguientes categorías funcionales:

  • 📏 Longitud (Metros, Kilómetros, Millas...)
  • ⚖️ Peso (Gramos, Kilogramos, Libras...)
  • 🌡️ Temperatura (Celsius, Fahrenheit, Kelvin)
  • 💾 Datos (Megabytes, Gigabytes...)
  • 🚀 Velocidad (km/h, nudos, mph)

Estructura del Proyecto

Trabajaremos enteramente en código puro sin .fxml. Dividiremos la responsabilidades en clases muy claras:

  1. Unit.java: Nuestro modelo de datos básico.
  2. ConverterLogic.java: El motor offline de conversiones matemáticas.
  3. ConverterUI.java: Aquí programaremos la vista, la interactividad de los ComboBox y nuestras increíbles animaciones.
  4. App.java: Clase principal para montar la escena flotante sin bordes.
  5. style.css: Estiloz avanzados incluyendo soporte para Modo Oscuro global.

1. Modelo de Datos y Motor Lógico

Primero necesitamos abstraer nuestras unidades. Usaremos un sistema de conversión proporcional basado en una unidad base por categoría (ej. gramos para Peso).

Unit.java

package com.conversor;

public class Unit {
    private String name;
    private String symbol;
    private double rateToBase;

    public Unit(String name, String symbol, double rateToBase) {
        this.name = name;
        this.symbol = symbol;
        this.rateToBase = rateToBase;
    }

    public String getName() { return name; }
    public String getSymbol() { return symbol; }
    public double getRateToBase() { return rateToBase; }

    @Override
    public String toString() {
        // Obliga al ComboBox a renderizar unicamente el nombre
        return name;
    }
}

ConverterLogic.java

Manejar la conversión es sencillo. Transformamos el valor original a su "Unidad Base" (multiplicando), y luego lo transformamos a la "Unidad Destino" (dividiendo). El único módulo que rompe esta regla proporcional es la Temperatura, por lo que agregamos una lógica condicional especial.

package com.conversor;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ConverterLogic {

    // Categorías
    public static final String CATEGORY_LENGTH = "Longitud";
    public static final String CATEGORY_WEIGHT = "Peso";
    public static final String CATEGORY_TEMP = "Temperatura";
    public static final String CATEGORY_DATA = "Datos";
    public static final String CATEGORY_SPEED = "Velocidad";

    private Map<String, List<Unit>> unitsByCategory;

    public ConverterLogic() {
        unitsByCategory = new HashMap<>();
        initializeData();
    }

    private void initializeData() {
        // --- Longitud (Base: Metros) ---
        List<Unit> length = new ArrayList<>();
        length.add(new Unit("Milimetros", "mm", 0.001));
        length.add(new Unit("Centimetros", "cm", 0.01));
        length.add(new Unit("Metros", "m", 1.0));
        length.add(new Unit("Kilometros", "km", 1000.0));
        length.add(new Unit("Millas", "mi", 1609.34));
        unitsByCategory.put(CATEGORY_LENGTH, length);

        // --- Temperatura (Caso especial NO proporcional) ---
        List<Unit> temp = new ArrayList<>();
        temp.add(new Unit("Celsius", "°C", 1.0));
        temp.add(new Unit("Fahrenheit", "°F", 1.0));
        temp.add(new Unit("Kelvin", "K", 1.0));
        unitsByCategory.put(CATEGORY_TEMP, temp);

        // (En el código completo verás las inicializaciones para Datos, Peso y Velocidad...)
    }

    public List<Unit> getUnitsForCategory(String category) {
        return unitsByCategory.getOrDefault(category, new ArrayList<>());
    }

    public double convert(double value, Unit from, Unit to, String currentCategory) {
        if (from == null || to == null) return 0.0;

        if (currentCategory.equals(CATEGORY_TEMP)) {
            return convertTemperature(value, from.getName(), to.getName());
        }

        // Lógica Proporcional general
        double valueInBase = value * from.getRateToBase();
        return valueInBase / to.getRateToBase();
    }

    private double convertTemperature(double value, String fromName, String toName) {
        if (fromName.equals(toName)) return value;
        double celsius = value;

        // Covertir a Celsius primero
        if (fromName.equals("Fahrenheit")) celsius = (value - 32) * 5 / 9;
        else if (fromName.equals("Kelvin")) celsius = value - 273.15;

        // Celsius a Destino
        if (toName.equals("Fahrenheit")) return (celsius * 9 / 5) + 32;
        else if (toName.equals("Kelvin")) return celsius + 273.15;
        return celsius;
    }
}

2. Hoja de Estilos Dinámica e Inteligente

Al igual que en tutoriales previos, declaramos nuestras variables en :root. Esta vez nos aseguraremos de que la regla de clase .dark-theme esté configurada para apuntar al nodo principal y sobreescribir dichos colores con las equivalencias para la noche.

style.css

/* Variables Globales */
.root {
    -bg-color: #f0f2f5;
    -card-bg: #ffffff;
    -text-main: #202124;
    -text-secondary: #5f6368;
    -accent-color: #1a73e8;

    -input-bg: #f8f9fa;
    -border-color: #dadce0;
    -shadow-color: rgba(0, 0, 0, 0.1);

    -fx-font-family: 'Segoe UI', sans-serif;
    -fx-background-color: transparent;
}

/* Variables Modo Oscuro */
.dark-theme {
    -bg-color: #202124;
    -card-bg: #2d2e30;
    -text-main: #ffffff;
    -text-secondary: #9aa0a6;
    -accent-color: #8ab4f8;

    -input-bg: #3c4043;
    -border-color: #5f6368;
    -shadow-color: rgba(0, 0, 0, 0.4);
}

/* Wrapper general de ventana */
.app-container {
    -fx-padding: 20px;
    -fx-alignment: center;
}

/* Tarjeta Principal */
.converter-card {
    -fx-background-color: -card-bg;
    -fx-background-radius: 20px;
    -fx-padding: 30px;
    -fx-effect: dropshadow(three-pass-box, -shadow-color, 20, 0, 0, 10);
}

/* Inputs y Selectores */
.input-field {
    -fx-background-color: -input-bg;
    -fx-text-fill: -text-main;
    -fx-font-size: 28px;
    -fx-font-weight: bold;
    -fx-background-radius: 12px;
    -fx-border-color: -border-color;
    -fx-border-radius: 12px;
    -fx-padding: 15px;
}
.input-field:focused {
    -fx-border-color: -accent-color;
    -fx-border-width: 2px;
}

.result-field {
    -fx-text-fill: -accent-color;
    -fx-font-size: 28px;
    -fx-font-weight: bold;
    -fx-padding: 15px;
}

3. Ensamblando la Interfaz Animada: ConverterUI.java

Aquí viene lo divertido. Observa cómo amarramos listeners interactivos a los campos texto y cómo hacemos uso de Hbox.setHgrow para evitar pestañas colapsadas en pantallas pequeñas. Y presta especial atención al método switchCategory, el cual orquesta una fluida transición de desvanecido (Fade Out + Fade In) acoplada a un pequeño rebote visual (ScaleTransition).

package com.conversor;

import javafx.animation.*;
import javafx.geometry.*;
import javafx.scene.Parent;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.scene.text.Text;
import javafx.util.Duration;
import javafx.stage.Stage;
import javafx.application.Platform;
import java.util.List;

public class ConverterUI {

    private VBox rootPane;
    private ConverterLogic logic;
    private boolean isDarkMode = false;

    // Componentes que animaremos
    private HBox categoryTabs;
    private VBox converterArea;
    private ComboBox<Unit> fromCombo;
    private ComboBox<Unit> toCombo;
    private TextField fromField;
    private Label toLabel;

    private String currentCategory = ConverterLogic.CATEGORY_LENGTH;

    public ConverterUI() {
        logic = new ConverterLogic();
        buildUI();
        loadCategory(currentCategory);
    }

    private void buildUI() {
        VBox appContainer = new VBox(20);
        appContainer.getStyleClass().add("app-container");

        VBox card = new VBox(25);
        card.getStyleClass().add("converter-card");

        // Creamos la barra superior (Top Bar con titulo y botón Cerrar)
        // ... [Ver código completo en descargas] ...

        // Pestañas de categorías dinámicas
        categoryTabs = new HBox(10);
        String[] categories = { ConverterLogic.CATEGORY_LENGTH, ConverterLogic.CATEGORY_WEIGHT, ConverterLogic.CATEGORY_TEMP, ConverterLogic.CATEGORY_DATA, ConverterLogic.CATEGORY_SPEED };
        String[] icons = { "📏", "⚖️", "🌡️", "💾", "🚀" };

        for (int i = 0; i < categories.length; i++) {
            final String cat = categories[i];
            Button btn = new Button(icons[i] + " " + cat);
            btn.getStyleClass().add("category-btn");
            if (cat.equals(currentCategory)) btn.getStyleClass().add("category-btn-active");

            // Forzamos el ensanchamiento fluido de pestañas
            btn.setMaxWidth(Double.MAX_VALUE);
            HBox.setHgrow(btn, Priority.ALWAYS);

            btn.setOnAction(e -> switchCategory(cat, btn));
            categoryTabs.getChildren().add(btn);
        }

        // Area de datos interactiva
        converterArea = new VBox(20);
        fromCombo = new ComboBox<>();
        toCombo = new ComboBox<>();

        fromField = new TextField("1");
        fromField.getStyleClass().add("input-field");
        // Escuchamos el teclado en todo momento
        fromField.textProperty().addListener((obs, oldV, newV) -> performConversion());

        // (Agregas los controles a la vista con sus íconos de Swap)

        card.getChildren().addAll(topBar, categoryTabs, converterArea);
        appContainer.getChildren().add(card);

        rootPane = new VBox(appContainer);
        rootPane.getStyleClass().add("light-theme");
    }

    // --- ANIMACIONES INCREIBLES EN JAVAFX ---

    private void switchCategory(String newCategory, Button clickedBtn) {
        if (currentCategory.equals(newCategory)) return;

        // Desvanecer capa anterior
        FadeTransition ftOut = new FadeTransition(Duration.millis(150), converterArea);
        ftOut.setToValue(0.0);

        ftOut.setOnFinished(e -> {
            updateCategoryUI(newCategory, clickedBtn);
            // Aparecer nueva capa inmediatamente
            FadeTransition ftIn = new FadeTransition(Duration.millis(150), converterArea);
            ftIn.setToValue(1.0);
            ftIn.play();
        });
        ftOut.play();
    }

    private void updateCategoryUI(String newCategory, Button clickedBtn) {
        currentCategory = newCategory;

        for (javafx.scene.Node node : categoryTabs.getChildren()) {
            node.getStyleClass().remove("category-btn-active");
        }
        clickedBtn.getStyleClass().add("category-btn-active");

        loadCategory(currentCategory);

        // Efecto físico "rebote"
        ScaleTransition st = new ScaleTransition(Duration.millis(200), converterArea);
        st.setFromY(0.95);
        st.setToY(1.0);
        st.play();
    }

    private void performConversion() {
        Unit from = fromCombo.getValue();
        Unit to = toCombo.getValue();
        String textVal = fromField.getText().trim();

        if (textVal.isEmpty() || from == null || to == null) {
            toLabel.setText("0");
            return;
        }

        try {
            double value = Double.parseDouble(textVal.replace(",", "."));
            double result = logic.convert(value, from, to, currentCategory);

            // Format to 6 decimales máximo
            String formatted = String.format("%.6f", result).replaceAll("0*$", "").replaceAll("\\.$", "");
            toLabel.setText(formatted.replace(",", "."));

            // Animación al escribir para confirmar respuesta
            ScaleTransition st = new ScaleTransition(Duration.millis(150), toLabel);
            st.setFromX(0.98); st.setFromY(0.98);
            st.setToX(1.0); st.setToY(1.0);
            st.play();

        } catch (NumberFormatException ex) {
            toLabel.setText("Inválido");
        }
    }
}

4. Mostrando la Ventana: App.java

¡Un detalle final importantísimo! Para que tu sombra visual y tus bordes curvos se dibujen correctamente fuera del marco de la app, requerimos que el ecosistema subyacente (Scene) sea de dimensiones superiores (ejemplo 650x550) y configurarlo Color.TRANSPARENT.

package com.conversor;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.scene.paint.Color;

public class App extends Application {

    @Override
    public void start(Stage primaryStage) {
        ConverterUI converterUI = new ConverterUI();

        // Ventana amplia para evitar recortar la sombra de la tarjeta
        Scene scene = new Scene(converterUI.getView(), 650, 550);
        scene.setFill(Color.TRANSPARENT);

        scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm());

        primaryStage.initStyle(StageStyle.TRANSPARENT);
        converterUI.setupDraggableWindow(primaryStage);

        primaryStage.setScene(scene);
        primaryStage.show();
    }
}

Si deseas explorar las variables de sombra, las fuentes o entender con mayor profundidad cómo interactúa el modelo Unit con nuestro JavaFX, puedes descargar el proyecto empaquetado y listo para compilar justo debajo:


forumComentarios

Deja tu comentario

progress_activityCargando comentarios...