In this tutorial we will create a simple product page, but we will use a 3D object. To do it we will utilize flutter_3d_controller
package.
This is a versatile 3D rendering package, that also allows to control animations. The goal is to create an interactive page, where the object slowly spins arround, but the user can hold it and rotate it as they please.
Here's a preview of how the complete page will look.
Pretty cool right? So lets begin by adding the package. In my case, I'm using version 1.3.1
.
You can paste it in pubspec.yaml
file manually, or using a command:
flutter pub add flutter_3d_controller
To create this functionality successfully, there are a few things to note. First of all we will need some 3D assets.
For a business project, these would likely be created in accordance to your product offering. For testing, you can download some free assets from sites like sketchfab.com.
I recommend working with glb
or gltf
formats, but flutter_3d_controller
will support other formats as well.
Next we will make an auto-rotate function that will periodically update the angle variable. We then can use this angle value in setCameraOrbit
function to rotate the 3D object. This now will rotate the object, but we also want to be able to interact with it via touch.
While it might seem intuitive to use the GestureDetector
to disable auto-rotation when we touch the object, this will not work as expected, as the Flutter3DViewer
widget handles touch events internally.
One way to work arround that is to use PointerEvent
. This will allow us to detect touch interactions and manage the auto-rotation behavior.
And here is the full code for creating such a page. Of course, feel free to design it however you like. While this example is for a product page, you can easily adapt the code for different purposes depending on your project's needs.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_3d_controller/flutter_3d_controller.dart';
class ProductPage extends StatefulWidget {
const ProductPage({super.key});
@override
State<ProductPage> createState() => _ProductPageState();
}
class _ProductPageState extends State<ProductPage> {
Flutter3DController myController = Flutter3DController();
Timer? _rotationTimer;
double angle = 0.0;
bool isRotating = true;
DateTime? lastUpdate;
bool userInteracting = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
_autoRotate();
}
@override
void dispose() {
WidgetsBinding.instance.pointerRouter
.removeGlobalRoute(_handlePointerEvent);
_rotationTimer?.cancel();
super.dispose();
}
void _handlePointerEvent(PointerEvent event) {
if (event is PointerDownEvent) {
_stopRotation();
userInteracting = true;
} else if (event is PointerUpEvent) {
userInteracting = false;
_resumeRotation();
}
}
void _autoRotate() {
_rotationTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) {
if (isRotating && !userInteracting) {
setState(() {
angle += 1.0;
});
// updating the camera every 200 miliseconds
if (lastUpdate == null ||
DateTime.now().difference(lastUpdate!) >
const Duration(milliseconds: 200)) {
lastUpdate = DateTime.now();
_updateCameraOrbit();
}
}
});
}
void _updateCameraOrbit() {
if (isRotating) {
myController.setCameraOrbit(angle, 70, 100);
}
}
void _stopRotation() {
setState(() {
isRotating = false;
});
}
void _resumeRotation() {
setState(() {
isRotating = true;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("RedBull (Original)"),
backgroundColor: Colors.indigo.shade900,
foregroundColor: Colors.white,
leading: const Icon(Icons.arrow_back_ios),
actions: [
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () {
// navigate to cart logic
},
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 300,
child: Flutter3DViewer(
controller: myController,
progressBarColor: Colors.transparent,
src: 'assets/3d/redbull_can.glb', // your 3d asset
),
),
const Divider(
color: Colors.grey,
thickness: 1.0,
),
const Text(
"RedBull (Original)",
style: TextStyle(fontSize: 26.0, fontWeight: FontWeight.w500),
),
const Text(
"250ml",
style: TextStyle(fontSize: 22.0, fontWeight: FontWeight.w300),
),
const SizedBox(
height: 4,
),
const Text('Nutritional Information (per 250 ml can):'),
Text(
'Calories: 110 kcal\n'
'Total Fat: 0 g\n'
'Cholesterol: 0 mg\n'
'Sodium: 105 mg (4% DV)\n'
'Total Carbohydrate: 28 g (9% DV)\n'
'Niacin (Vitamin B3): 22 mg (110% DV)\n'
'Vitamin B6: 5 mg (250% DV)\n'
'Vitamin B12: 5.1 µg (213% DV)\n'
'Caffeine: 80 mg\n'
'Taurine: 1000 mg\n'
'Glucuronolactone: 600 mg\n'
'Inositol: 50 mg\n',
style: TextStyle(
color: Colors.indigo.shade900,
fontSize: 12.0,
fontWeight: FontWeight.w300),
),
const SizedBox(
height: 20,
),
// add to cart button
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
// decrease count
},
),
const Text(
'1', // here should be the count
style: TextStyle(fontSize: 18.0),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
// increase count
},
),
],
),
ElevatedButton(
onPressed: () {
// add to cart logic goes here
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo.shade900,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 20.0, vertical: 12.0),
),
child: const Text(
'Add to Cart',
style: TextStyle(fontSize: 18.0),
),
),
],
)
],
),
),
);
}
}