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
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: [
//* 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.