Closed
Description
Steps to reproduce
Hey, my Flutter map with custom hillshade and elevation layers is buttery smooth on iOS 14, but iOS 17 and up is super jerky and flickery. Why's iOS 14 so much better, and what's changed in Flutter and animation to cause this? The code's the same, and empeller's enabled on both. The ipad model is the same with different IOS version running. The ipad above is running ios 14 and below its ios 17
The video is attached below
https://www.youtube.com/shorts/uYCd1qMzeEE
Code sample
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' as latLng;
void main() {
runApp(const MaterialApp(home: TerrainFlickerDemo()));
}
class TerrainFlickerDemo extends StatefulWidget {
const TerrainFlickerDemo({super.key});
@override
State<TerrainFlickerDemo> createState() => _TerrainFlickerDemoState();
}
class _TerrainFlickerDemoState extends State<TerrainFlickerDemo> {
final ValueNotifier<double> _referenceAltitude = ValueNotifier<double>(6000.0);
final ValueNotifier<double> _terrainResolution = ValueNotifier<double>(100.0);
late MapController mapController;
Timer? _terrainUpdateTimer;
bool _isSliderDragging = false;
double _lastAppliedAltitude = 0.0;
double _currentZoomLevel = 4.0;
// Get bucket size for altitude changes
double get _bucketSizeFeet => math.max(_terrainResolution.value, 100.0);
@override
void initState() {
super.initState();
mapController = MapController();
// Monitor zoom changes
mapController.mapEventStream.listen((event) {
if (event is MapEventMove) {
_currentZoomLevel = event.camera.zoom;
}
});
}
// Schedule terrain updates with zoom-based delays
void _scheduleTerrainUpdate(double targetAltitude, {bool highZoomDelay = false}) {
_terrainUpdateTimer?.cancel();
// Use longer delays for high zoom levels
int delay = highZoomDelay
? (_currentZoomLevel > 10 ? 300 : _currentZoomLevel > 8 ? 200 : 150)
: 100;
_terrainUpdateTimer = Timer(Duration(milliseconds: delay), () {
final bucketedValue = (targetAltitude / _bucketSizeFeet).round() * _bucketSizeFeet;
if (bucketedValue != _lastAppliedAltitude) {
_lastAppliedAltitude = bucketedValue;
_referenceAltitude.value = bucketedValue;
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
mapController: mapController,
options: MapOptions(
initialCenter: const latLng.LatLng(39.8, -98.5),
initialZoom: 4.0,
maxZoom: 17,
minZoom: 3,
),
children: [
// Terrain layer implementation causing flickering
Opacity(
opacity: 0.6,
child: ValueListenableBuilder<double>(
valueListenable: _referenceAltitude,
builder: (context, altitude, _) {
final bucketedValue = (_referenceAltitude.value / _bucketSizeFeet).round();
return AnimatedSwitcher(
duration: const Duration(milliseconds: 10),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: RepaintBoundary(
key: ValueKey('terrain_$bucketedValue'),
child: LercTileLayer(
assetPath: 'assets/elevation.lerc2',
referenceAltitude: _referenceAltitude,
terrainResolution: _terrainResolution,
),
),
);
},
),
),
],
),
// Slider control that triggers flickering
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ValueListenableBuilder<double>(
valueListenable: _referenceAltitude,
builder: (context, value, _) {
return Text(
'Altitude: ${value.toInt()}ft',
style: const TextStyle(fontSize: 16),
);
},
),
const SizedBox(height: 8),
GestureDetector(
onHorizontalDragStart: (_) => _isSliderDragging = true,
onHorizontalDragEnd: (_) {
_isSliderDragging = false;
_scheduleTerrainUpdate(_referenceAltitude.value);
},
child: ValueListenableBuilder<double>(
valueListenable: _referenceAltitude,
builder: (context, value, _) {
return Slider(
value: value,
min: 0,
max: 30000,
divisions: (30000 / _terrainResolution.value).round(),
label: '${value.round()}ft',
onChanged: (newValue) {
if (_currentZoomLevel > 7 && _isSliderDragging) {
// Skip updates during high zoom dragging
_referenceAltitude.value = newValue;
} else {
_scheduleTerrainUpdate(
newValue,
highZoomDelay: _currentZoomLevel > 7,
);
}
},
);
},
),
),
],
),
),
),
),
],
),
);
}
@override
void dispose() {
_referenceAltitude.dispose();
_terrainResolution.dispose();
mapController.dispose();
_terrainUpdateTimer?.cancel();
super.dispose();
}
}
Performance profiling on master channel
- The issue still persists on the master channel
Timeline Traces
Timeline Traces JSON
[Paste the Timeline Traces here]
Video demonstration
https://youtube.com/shorts/uYCd1qMzeEE?feature=shared
https://www.youtube.com/shorts/uYCd1qMzeEE
What target platforms are you seeing this bug on?
iOS
OS/Browser name and version | Device information
ipad pro 10.5 ios 14
ipad pro 10.5 ios 17
Does the problem occur on emulator/simulator as well as on physical devices?
Unknown
Is the problem only reproducible with Impeller?
N/A
Logs
No response
Flutter Doctor output
Doctor output
(base) pannam@MacBookPro flightcanvas_terrain % flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.29.0, on macOS 15.2 24C101 darwin-x64, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.3)
[✓] VS Code (version 1.100.0)
[✓] VS Code (version 1.99.0-insider)
[✓] Connected device (5 available)
[✓] Network resources
• No issues found!