I\'m doing a UI in flutter and right now it look great on my emulator but I\'m afraid it will break if screen size is different. What is the best practice to prevent this, e
Use flutter widget LayoutBuilder
every time you use it it'll give you a BoxConstraint
what it can do is it'll tell you what space (maxHeight, maxWidth etc) is available for further children down the widget tree, you use that detail to divide the space within the children
For example
if you want to divide the available width within 3 Containers
do
Row(
children: <Widget>[
Container(
width: constraints.maxWidth / 3,
),
Container(
width: constraints.maxWidth / 3,
),
Container(
width: constraints.maxWidth / 3,
),
],
),
you can do the same with the font sizes
So I believe you're looking for a scaling solution that maintains the proportions (i.e. ratios) of your UI intact while scaling up and down to fit different screen densities. The way to achieve this is to apply a Ratio-Scaling solution to your project.
Outline of Ratio-Scaling Process:
Step 1: Define a fixed scaling ratio [Height:Width => 2:1 ratio] in pixels.
Step 2: Specify whether your app is a full screen app or not (i.e. define whether the Status Bar plays a role in your height scaling).
Step 3: Scale your entire UI (from the App bar to the tiniest text) on the basis of percentages using the following process [code].
IMPORTANT CODE unit:
=> McGyver [ a play on 'MacGyver' ] - the class that does the important ratio-scaling.
// Imports: Third-Party.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// Imports: Local [internal] packages.
import 'package:pixel_perfect/utils/stringr.dart';
import 'package:pixel_perfect/utils/enums_all.dart';
// Exports: Local [internal] packages.
export 'package:pixel_perfect/utils/enums_all.dart';
// 'McGyver' - the ultimate cool guy (the best helper class any app can ask for).
class McGyver {
static final TAG_CLASS_ID = "McGyver";
static double _fixedWidth; // Defined in pixels !!
static double _fixedHeight; // Defined in pixels !!
static bool _isFullScreenApp = false; // Define whether app is a fullscreen app [true] or not [false] !!
static void hideSoftKeyboard() {
SystemChannels.textInput.invokeMethod("TextInput.hide");
}
static double roundToDecimals(double val, int places) {
double mod = pow(10.0, places);
return ((val * mod).round().toDouble() / mod);
}
static Orientation setScaleRatioBasedOnDeviceOrientation(BuildContext ctx) {
Orientation scaleAxis;
if(MediaQuery.of(ctx).orientation == Orientation.portrait) {
_fixedWidth = 420; // Ration: 1 [width]
_fixedHeight = 840; // Ration: 2 [height]
scaleAxis = Orientation.portrait; // Shortest axis == width !!
} else {
_fixedWidth = 840; // Ration: 2 [width]
_fixedHeight = 420; // Ration: 1 [height]
scaleAxis = Orientation.landscape; // Shortest axis == height !!
}
return scaleAxis;
}
static int rsIntW(BuildContext ctx, double scaleValue) {
// ---------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled integer - Scaling based on device's width. //
// ---------------------------------------------------------------------------------------- //
final double _origVal = McGyver.rsDoubleW(ctx, scaleValue);
return McGyver.roundToDecimals(_origVal, 0).toInt();
}
static int rsIntH(BuildContext ctx, double scaleValue) {
// ---------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled integer - Scaling based on device's height. //
// ---------------------------------------------------------------------------------------- //
final double _origVal = McGyver.rsDoubleH(ctx, scaleValue);
return McGyver.roundToDecimals(_origVal, 0).toInt();
}
static double rsDoubleW(BuildContext ctx, double wPerc) {
// ------------------------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled double - scaling based on device's screen width in relation to fixed width ration. //
// INPUTS: - 'ctx' [context] -> BuildContext //
// - 'wPerc' [double] -> Value (as a percentage) to be ratio-scaled in terms of width. //
// OUTPUT: - 'rsWidth' [double] -> Ratio-scaled value. //
// ------------------------------------------------------------------------------------------------------- //
final int decimalPlaces = 14; //* NB: Don't change this value -> has big effect on output result accuracy !!
Size screenSize = MediaQuery.of(ctx).size; // Device Screen Properties (dimensions etc.).
double scrnWidth = screenSize.width.floorToDouble(); // Device Screen maximum Width (in pixels).
McGyver.setScaleRatioBasedOnDeviceOrientation(ctx); //* Set Scale-Ratio based on device orientation.
double rsWidth = 0; //* OUTPUT: 'rsWidth' == Ratio-Scaled Width (in pixels)
if (scrnWidth == _fixedWidth) {
//* Do normal 1:1 ratio-scaling for matching screen width (i.e. '_fixedWidth' vs. 'scrnWidth') dimensions.
rsWidth = McGyver.roundToDecimals(scrnWidth * (wPerc / 100), decimalPlaces);
} else {
//* Step 1: Calculate width difference based on width scale ration (i.e. pixel delta: '_fixedWidth' vs. 'scrnWidth').
double wPercRatioDelta = McGyver.roundToDecimals(100 - ((scrnWidth / _fixedWidth) * 100), decimalPlaces); // 'wPercRatioDelta' == Width Percentage Ratio Delta !!
//* Step 2: Calculate primary ratio-scale adjustor (in pixels) based on input percentage value.
double wPxlsInpVal = (wPerc / 100) * _fixedWidth; // 'wPxlsInpVal' == Width in Pixels of Input Value.
//* Step 3: Calculate secondary ratio-scale adjustor (in pixels) based on primary ratio-scale adjustor.
double wPxlsRatDelta = (wPercRatioDelta / 100) * wPxlsInpVal; // 'wPxlsRatDelta' == Width in Pixels of Ratio Delta (i.e. '_fixedWidth' vs. 'scrnWidth').
//* Step 4: Finally -> Apply ratio-scales and return value to calling function / instance.
rsWidth = McGyver.roundToDecimals((wPxlsInpVal - wPxlsRatDelta), decimalPlaces);
}
return rsWidth;
}
static double rsDoubleH(BuildContext ctx, double hPerc) {
// ------------------------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled double - scaling based on device's screen height in relation to fixed height ration. //
// INPUTS: - 'ctx' [context] -> BuildContext //
// - 'hPerc' [double] -> Value (as a percentage) to be ratio-scaled in terms of height. //
// OUTPUT: - 'rsHeight' [double] -> Ratio-scaled value. //
// ------------------------------------------------------------------------------------------------------- //
final int decimalPlaces = 14; //* NB: Don't change this value -> has big effect on output result accuracy !!
Size scrnSize = MediaQuery.of(ctx).size; // Device Screen Properties (dimensions etc.).
double scrnHeight = scrnSize.height.floorToDouble(); // Device Screen maximum Height (in pixels).
double statsBarHeight = MediaQuery.of(ctx).padding.top; // Status Bar Height (in pixels).
McGyver.setScaleRatioBasedOnDeviceOrientation(ctx); //* Set Scale-Ratio based on device orientation.
double rsHeight = 0; //* OUTPUT: 'rsHeight' == Ratio-Scaled Height (in pixels)
if (scrnHeight == _fixedHeight) {
//* Do normal 1:1 ratio-scaling for matching screen height (i.e. '_fixedHeight' vs. 'scrnHeight') dimensions.
rsHeight = McGyver.roundToDecimals(scrnHeight * (hPerc / 100), decimalPlaces);
} else {
//* Step 1: Calculate height difference based on height scale ration (i.e. pixel delta: '_fixedHeight' vs. 'scrnHeight').
double hPercRatioDelta = McGyver.roundToDecimals(100 - ((scrnHeight / _fixedHeight) * 100), decimalPlaces); // 'hPercRatioDelta' == Height Percentage Ratio Delta !!
//* Step 2: Calculate height of Status Bar as a percentage of the height scale ration (i.e. 'statsBarHeight' vs. '_fixedHeight').
double hPercStatsBar = McGyver.roundToDecimals((statsBarHeight / _fixedHeight) * 100, decimalPlaces); // 'hPercStatsBar' == Height Percentage of Status Bar !!
//* Step 3: Calculate primary ratio-scale adjustor (in pixels) based on input percentage value.
double hPxlsInpVal = (hPerc / 100) * _fixedHeight; // 'hPxlsInpVal' == Height in Pixels of Input Value.
//* Step 4: Calculate secondary ratio-scale adjustors (in pixels) based on primary ratio-scale adjustor.
double hPxlsStatsBar = (hPercStatsBar / 100) * hPxlsInpVal; // 'hPxlsStatsBar' == Height in Pixels of Status Bar.
double hPxlsRatDelta = (hPercRatioDelta / 100) * hPxlsInpVal; // 'hPxlsRatDelta' == Height in Pixels of Ratio Delat (i.e. '_fixedHeight' vs. 'scrnHeight').
//* Step 5: Check if '_isFullScreenApp' is true and adjust 'Status Bar' scalar accordingly.
double hAdjStatsBarPxls = _isFullScreenApp ? 0 : hPxlsStatsBar; // Set to 'zero' if FULL SCREEN APP !!
//* Step 6: Finally -> Apply ratio-scales and return value to calling function / instance.
rsHeight = McGyver.roundToDecimals(hPxlsInpVal - (hPxlsRatDelta + hAdjStatsBarPxls), decimalPlaces);
}
return rsHeight;
}
static Widget rsWidget(BuildContext ctx, Widget inWidget,
double percWidth, double percHeight, {String viewID}) {
// ---------------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled "SizedBox" Widget - Scaling based on device's width & height. //
// ---------------------------------------------------------------------------------------------- //
return SizedBox(
width: Scalar.rsDoubleW(ctx, percWidth),
height: Scalar.rsDoubleH(ctx, percHeight),
child: inWidget,
);
}
//* SPECIAL 'rsWidget' that has both its height & width ratio-scaled based on 'width' alone !!
static Widget rsWidgetW(BuildContext ctx, Widget inWidget,
double percWidth, double percHeight, {String viewID}) {
// ---------------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled "SizedBox" Widget - Scaling based on device's width ONLY !! //
// ---------------------------------------------------------------------------------------------- //
return SizedBox(
width: Scalar.rsDoubleW(ctx, percWidth),
height: Scalar.rsDoubleW(ctx, percHeight),
child: inWidget,
);
}
static Widget rsText(BuildContext ctx, String text, {double fontSize,
Color textColor, Anchor txtLoc, FontWeight fontWeight}) {
// ---------------------------------------------------------------------------------------- //
// INFO: Ratio-Scaled Text Widget - Default Font Weight == NORMAL !! //
// ---------------------------------------------------------------------------------------- //
// Scale the Font Size (based on device's screen width).
double txtScaleFactor = MediaQuery.of(ctx).textScaleFactor;
double _rsFontSize = (fontSize != null) ? McGyver.rsDoubleW(ctx, fontSize) : McGyver.rsDoubleW(ctx, 2.5);
TextAlign _txtLoc;
if (txtLoc == Anchor.left) {
_txtLoc = TextAlign.left;
} else if (txtLoc == Anchor.middle) {
_txtLoc = TextAlign.center;
} else {
_txtLoc = TextAlign.right;
}
return Text(
text,
textAlign: _txtLoc,
style: TextStyle(
fontFamily: Stringr.strAppFontFamily,
fontSize: (_rsFontSize / txtScaleFactor) * 1.0,
color: (textColor != null) ? textColor : Colors.black,
fontWeight: (fontWeight != null) ? fontWeight : FontWeight.normal,
),
);
}
}
The McGyver class covers the entire process outlined under Steps 1 & 2 of the Ratio-Scaling Process. All that is then left to do is to apply Step 3 in the build process as follows...
AppBar Code Snippet: [ code that creates the AppBar in the image - Fig. 1 - above ]
Container(
color: Colors.blue[500],
width: McGyver.rsDoubleW(con, 100.5),
height: McGyver.rsDoubleH(con, 8.5),
child: Row(
children: <Widget>[
//* Hamburger Button => Button 1.
Padding(
padding: EdgeInsets.fromLTRB(_padLeft, _padTop, 0, _padBottom),
child: Container(
color: Colors.yellow,
width: _appBarBtnsWidth,
height: _appBarBtnsHeight,
child: Center(child: McGyver.rsText(context, "1", fontSize: 5.5, fontWeight: FontWeight.bold, textColor: Colors.red),),
),
),
//* AppBar Info Text (center text).
Padding(
padding: EdgeInsets.only(left: McGyver.rsDoubleW(con, 3.5), right: McGyver.rsDoubleW(con, 3.5)),
child: Container(
// color: Colors.pink,
width: McGyver.rsDoubleW(context, 52.5),
child: McGyver.rsText(con, "100% Ratio-Scaled UI", fontSize: 4.5, textColor: Colors.white, fontWeight: FontWeight.bold, txtLoc: Anchor.left),
),
),
//* Right Button Group - LEFT Button => Button 2.
Padding(
padding: EdgeInsets.fromLTRB(McGyver.rsDoubleW(con, 0), _padTop, McGyver.rsDoubleH(con, 1.5), _padBottom),
child: Container(
color: Colors.black,
width: _appBarBtnsWidth,
height: _appBarBtnsHeight,
child: Center(child: McGyver.rsText(context, "2", fontSize: 5.5, fontWeight: FontWeight.bold, textColor: Colors.white),),
),
),
//* Right Button Group - RIGHT Button => Button 3.
Padding(
padding: EdgeInsets.fromLTRB(McGyver.rsDoubleW(con, 0), _padTop, 0, _padBottom),
child: Container(
color: Colors.pink,
width: _appBarBtnsWidth,
height: _appBarBtnsHeight,
child: Center(child: McGyver.rsText(context, "3", fontSize: 5.5, fontWeight: FontWeight.bold, textColor: Colors.yellow),),
),
),
],
),
),
Limitations of Ratio-Scaling Code
This ratio-scaling solution worked surprisingly well on ALL devices tested [7 physical devices & 1 emulator] - but it clearly has some issues with:
1. Text
2. Padding
3. Extreme Aspect Ratios
- Text scale factor is nullified (deactivated by this code) - so no SP for text when using the McGyver.rsText()
feature. You want your UI to have the exact proportions at ANY scale or screen density.
- There is some weird scaling [ going on behind the scenes ] with padding in Flutter (and Android in general).
- Devices with extremely weird aspect ratios (i.e. width:height pixel ratios) also cause the proportions of the UI to distort somewhat.
Aside from these three issues this ratio-scaling approach works well enough for me to use it as my only scaling solution in all my flutter projects. I hope it will help other programmers on the same quest as I am. Any improvement to this approach / code is always welcomed.