Start Debugging

Bildschirmausrichtung für einen einzelnen Screen in Flutter sperren (und warum iPad-Multitasking sie ignoriert)

Sperren Sie eine Flutter-Route mit SystemChrome.setPreferredOrientations auf Quer- oder Hochformat, ohne den Rest der App zu stören: eine stapelbasierte Sperre, die pushReplacement übersteht und beim Pop zurückgesetzt wird, warum die leere Liste das falsche Zurücksetzen ist, warum iPad-Multitasking und große Android-16-Bildschirme die Sperre ignorieren und was UIRequiresFullScreen nach iPadOS 26 und dem iOS-27-SDK bedeutet. Getestet mit Flutter 3.47.8, Dart 3.13.7 und Xcode 27.1.

Um einen Screen in Flutter zu sperren, rufen Sie SystemChrome.setPreferredOrientations auf, sobald die Route erscheint, und stellen die normalen Ausrichtungen der App wieder her, wenn die Route verlassen wird. Schreiben Sie nicht die naive Variante, bei der initState auf Querformat setzt und dispose auf Hochformat zurücksetzt. In einem Widget-Test mit Flutter 3.47.8 (Dart 3.13.7) stellt diese Variante das Hochformat erst wieder her, nachdem die Pop-Animation beendet ist. Schlimmer noch: pushReplacement zwischen zwei gesperrten Screens lässt den neuen Screen im Hochformat zurück. Verwenden Sie die kleine stapelbasierte Sperre unten und geben Sie sie in PopScope.onPopInvokedWithResult frei. Selbst dann ist die Sperre nur eine Anfrage und keine Garantie. iPadOS ignoriert sie, solange die App Multitasking unterstützt (Split View, Slide Over und das Fenstersystem von iPadOS 26). Android 16 ignoriert sie auf Displays ab 600 dp Breite, sobald Sie API 36 anvisieren, und Flutter 3.47.8 visiert standardmäßig API 36 an. Der alte iPad-Ausweg UIRequiresFullScreen ist seit iPadOS 26 veraltet und verhindert das Anpassen der Fenstergröße nicht mehr, wenn Sie mit dem iOS-27-SDK kompilieren.

Was setPreferredOrientations auf jeder Plattform tatsächlich bewirkt

SystemChrome.setPreferredOrientations(List<DeviceOrientation>) ist ein einzelner Platform-Channel-Aufruf, SystemChrome.setPreferredOrientations, der eine Liste von Enum-Namen übermittelt. Er verändert einen App-weiten Zustand. Nichts daran ist auf ein Widget oder eine Route begrenzt. Was zuletzt gesetzt wurde, bleibt in Kraft, bis etwas anderes es erneut setzt. Das gilt auch für einen Hot Restart, weil die Einstellung im nativen View Controller bzw. in der Activity liegt und nicht in Dart.

Die Plattformen behandeln sie unterschiedlich, und diese Unterschiede zählen, sobald Sie eine Sperre “rückgängig machen” wollen:

Daraus folgen zwei Dinge. Erstens bedeutet [] “was auch immer das Betriebssystem erlaubt” und nicht “der Standard der App”. Stellen Sie immer auf eine explizite Liste zurück. Zweitens ist der Aufruf Fire-and-forget. Das Warten auf das Future sagt Ihnen, dass die Nachricht zugestellt wurde, nicht, dass sich der Bildschirm gedreht hat.

Die naive Sperre pro Screen und wie sie scheitert

Das ist die Variante, die die meisten Antworten vorschlagen:

// Flutter 3.47.8, Dart 3.13.7 -- naive, do not ship
class _VideoPageState extends State<VideoPage> {
  @override
  void initState() {
    super.initState();
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);
  }

  @override
  void dispose() {
    SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
    super.dispose();
  }
}

Ich habe sie in einem Widget-Test ausgeführt, der SystemChannels.platform abfängt und jeden Ausrichtungsaufruf zwischen den Navigationsereignissen protokolliert. Hier ist das Log unter Flutter 3.47.8:

naive push/pop: [landscapeLeft,landscapeRight] -- pop -- first frame of pop animation [portraitUp] -- pop animation done
naive replace:  [landscapeLeft,landscapeRight] -- replace [landscapeLeft,landscapeRight] [portraitUp]  => final [portraitUp]

Die erste Zeile zeigt den Timing-Fehler. dispose läuft erst, nachdem die Exit-Transition der Route abgeschlossen ist. Der vorherige Screen gleitet also zurück ins Bild, während das Gerät noch auf Querformat gesperrt ist, und springt erst dann ins Hochformat. Auf einem echten Telefon sieht der Nutzer den vorherigen Screen während der gesamten Pop-Transition in einem Querformat-Layout einfahren, und erst danach dreht er sich.

Die zweite Zeile ist der Korrektheitsfehler. Bei pushReplacement (oder replace / go von go_router zu einer Schwesterroute) läuft das initState der neuen Route vor dem dispose der alten Route. Die eingehende Videoseite fordert Querformat an. Dann “räumt” die ausgehende Videoseite “auf” und erzwingt Hochformat. Der Endzustand ist Hochformat auf einem Screen, der Querformat wollte. Dasselbe passiert immer, wenn sich die Lebenszeiten zweier gesperrter Screens überlappen, etwa in einer TabBarView oder einer PageView, die Nachbarn am Leben hält.

Eine Sperre mit Besitzer, Stapel und Freigabe beim Pop

Die Lösung besteht darin, die Ausrichtung nicht mehr als etwas zu behandeln, das jeder Screen setzt und zurücksetzt. Halten Sie stattdessen einen App-weiten Stapel von Anfragen. Jeder Screen legt seinen eigenen Eintrag ab und entfernt nur diesen Eintrag, und der oberste Eintrag des Stapels (oder der App-Standard) wird an die Plattform gesendet:

// Flutter 3.47.8, Dart 3.13.7
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

/// App-wide orientation stack. The top entry wins; when it is empty the
/// app default applies.
class OrientationLocks {
  OrientationLocks._();

  static List<DeviceOrientation> appDefault = const [DeviceOrientation.portraitUp];
  static final List<(Object, List<DeviceOrientation>)> _stack = [];

  static void push(Object owner, List<DeviceOrientation> orientations) {
    _stack.removeWhere((e) => identical(e.$1, owner));
    _stack.add((owner, orientations));
    _apply();
  }

  static void remove(Object owner) {
    final before = _stack.length;
    _stack.removeWhere((e) => identical(e.$1, owner));
    if (_stack.length != before) _apply();
  }

  static void _apply() {
    final target = _stack.isEmpty ? appDefault : _stack.last.$2;
    SystemChrome.setPreferredOrientations(target);
  }
}

Das Widget, das ihn verwendet, legt seine Sperre ab, sobald seine Route die aktuelle wird. Es gibt die Sperre an zwei Stellen frei: wenn ein Pop beginnt (über PopScope) und in dispose als Rückfallebene für Routen, die ohne Pop entfernt werden, etwa die alte Route bei einem pushReplacement:

// Flutter 3.47.8, Dart 3.13.7
/// Locks orientation while this widget's route is the current route.
class OrientationLock extends StatefulWidget {
  const OrientationLock({
    super.key,
    required this.orientations,
    required this.child,
  });

  final List<DeviceOrientation> orientations;
  final Widget child;

  @override
  State<OrientationLock> createState() => _OrientationLockState();
}

class _OrientationLockState extends State<OrientationLock> {
  ModalRoute<Object?>? _route;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final route = ModalRoute.of(context);
    if (route != _route) {
      _route = route;
      // isCurrent is false while another route covers this one.
      if (route == null || route.isCurrent) {
        OrientationLocks.push(this, widget.orientations);
      }
    }
  }

  @override
  void didUpdateWidget(OrientationLock oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (!listEquals(oldWidget.orientations, widget.orientations)) {
      OrientationLocks.push(this, widget.orientations);
    }
  }

  @override
  void dispose() {
    OrientationLocks.remove(this);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return PopScope<Object?>(
      onPopInvokedWithResult: (didPop, result) {
        // Release the lock when the pop starts, not when the exit
        // animation ends, so the previous screen does not animate in
        // under the wrong orientation.
        if (didPop) OrientationLocks.remove(this);
      },
      child: widget.child,
    );
  }
}

Auf einer Route verwenden Sie es mit einem einzigen Wrapper:

// Flutter 3.47.8, Dart 3.13.7
Navigator.of(context).push(MaterialPageRoute<void>(
  builder: (_) => const OrientationLock(
    orientations: [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight],
    child: VideoPage(),
  ),
));

Derselbe Test-Harness, ausgeführt gegen diese Version:

stack push/pop: [landscapeLeft,landscapeRight] -- pop [portraitUp] -- first frame of pop animation -- pop animation done
stack replace:  [landscapeLeft,landscapeRight] -- replace [landscapeLeft,landscapeRight] [landscapeLeft,landscapeRight]  => final [landscapeLeft,landscapeRight]

Das Hochformat wird im selben Frame angefordert, in dem der Pop beginnt, sodass Drehung und Rückwärts-Transition gemeinsam ablaufen. Nach dem Ersetzen bleibt das Video im Querformat, weil das Entfernen des Eintrags der ausgehenden Seite den Eintrag der eingehenden Seite erneut anwendet und nicht den Standard.

Hier steckt eine Designentscheidung drin. Eine Route ohne eigene OrientationLock erbt die nächste Sperre darunter im Stapel. Wenn ein Querformat-Player ein schlichtes Einstellungs-Sheet öffnet, ist auch das Sheet im Querformat. Wenn Sie möchten, dass “alles ohne Sperre im Hochformat ist”, umschließen Sie diese Routen mit einer expliziten OrientationLock(orientations: [DeviceOrientation.portraitUp]). Bei go_router setzen Sie den Wrapper in den builder oder pageBuilder der Route. ModalRoute.of und PopScope funktionieren im verschachtelten Navigator einer ShellRoute genauso, was der Beitrag verschachtelte Routen und Deep Links mit go_router durchgeht.

Den App-Standard im nativen Projekt setzen, nicht nur in Dart

Wenn die ganze App bis auf einen Screen im Hochformat läuft, deklarieren Sie das Hochformat auch nativ, damit der Startbildschirm und der erste Frame bereits stimmen:

  1. Fügen Sie in android/app/src/main/AndroidManifest.xml dem Element MainActivity das Attribut android:screenOrientation="portrait" hinzu.
  2. Lassen Sie in ios/Runner/Info.plist UISupportedInterfaceOrientations (iPhone) und UISupportedInterfaceOrientations~ipad jede Ausrichtung auflisten, die irgendein Screen jemals braucht. Flutter kann nur einschränken, was die Info.plist erlaubt. Es kann nichts hinzufügen. Die Vorlage von 3.47.8 listet für das iPhone Hochformat und beide Querformate und für das iPad alle vier Ausrichtungen auf.
  3. Setzen Sie in main den Wert OrientationLocks.appDefault und wenden Sie ihn vor runApp an:
// Flutter 3.47.8, Dart 3.13.7
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  OrientationLocks.appDefault = const [DeviceOrientation.portraitUp];
  await SystemChrome.setPreferredOrientations(OrientationLocks.appDefault);
  runApp(const MyApp());
}

Beschränken Sie die Info.plist des iPhones nicht auf Hochformat, um dann aus Dart Querformat anzufordern. Unter iOS 16+ schlägt die Geometrieanfrage fehl, weil die Scene keine der angeforderten Ausrichtungen unterstützt. Sie schlägt stillschweigend fehl, abgesehen von einer Logzeile in Xcode.

Warum iPad-Multitasking die Sperre ignoriert

Der Doc-Kommentar zu setPreferredOrientations in Flutter 3.47.8 sagt es deutlich: “This setting will only be respected on iPad if multitasking is disabled.” Der Grund liegt in UIKit, nicht in Flutter. Wenn eine App den Bildschirm teilen kann (Split View, Slide Over, Stage Manager und die frei skalierbaren Fenster, die mit iPadOS 26 kamen), gehören Größe und Ausrichtung der Scene dem System. Der Nutzer kann das iPad drehen, während Ihre App in einem Side-by-side-Layout läuft. Das System dreht nicht den ganzen Bildschirm für eine einzelne App, daher wird supportedInterfaceOrientations nicht mehr beachtet. Flutter sendet die Anfrage weiterhin, UIKit reagiert nur nicht darauf.

Jahrelang bestand der dokumentierte Workaround, den auch die Flutter-Dokumentation noch erwähnt, darin, Multitasking abzuwählen. Dazu haben Sie in Xcode “Requires full screen” angehakt, was UIRequiresFullScreen = YES in die Info.plist schreibt. Das ist keine langfristige Antwort mehr. Apples TN3192 sagt:

Xcode 27.1 liefert das iOS-27-SDK mit. Bei einer Flutter-App, die Sie heute kompilieren, wählt das Anhaken von “Requires full screen” die App unter iOS 27 also nicht mehr vom Skalieren ab, und Apple hat Entwicklern gesagt, sich nicht mehr darauf zu verlassen. Threads in den Entwicklerforen aus dem iPadOS-26.x-Zyklus berichten außerdem, dass reine Querformat-Apps im Hochformat erscheinen, wenn die Rotationssperre des Geräts aus ist. Behandeln Sie jede iPad-Ausrichtungssperre als Best Effort.

Apple bietet stattdessen eine temporäre Sperre an. Ein View Controller kann prefersInterfaceOrientationLocked überschreiben und setNeedsUpdateOfPrefersInterfaceOrientationLocked() aufrufen. Das bittet das System, die aktuelle Ausrichtung der Scene beizubehalten, solange dieser Controller sichtbar ist, zum Beispiel bei einer Kameraaufnahme oder einer Lenkgeste in einem Spiel. TN3192 stellt klar, dass das System die Beachtung der Einstellung “does not guarantee”. Der FlutterViewController von Flutter 3.47.8 überschreibt diese Eigenschaft nicht. Sie können das in einer Unterklasse tun und per Method Channel steuern:

// Flutter 3.47.8, Xcode 27.1, iOS 26+ for prefersInterfaceOrientationLocked
import Flutter
import UIKit

class LockableFlutterViewController: FlutterViewController {
  private var locked = false
  private var channel: FlutterMethodChannel?

  override func viewDidLoad() {
    super.viewDidLoad()
    channel = FlutterMethodChannel(
      name: "app/orientation_lock", binaryMessenger: engine.binaryMessenger)
    channel?.setMethodCallHandler { [weak self] call, result in
      guard let self, call.method == "setLocked", let value = call.arguments as? Bool else {
        result(FlutterMethodNotImplemented)
        return
      }
      self.locked = value
      if #available(iOS 26.0, *) {
        self.setNeedsUpdateOfPrefersInterfaceOrientationLocked()
      }
      result(nil)
    }
  }

  override var prefersInterfaceOrientationLocked: Bool {
    locked
  }
}

Ändern Sie in ios/Runner/Base.lproj/Main.storyboard die customClass des Root View Controllers von FlutterViewController zu LockableFlutterViewController (mit customModuleProvider="target"). Rufen Sie sie dann aus Dart auf:

// Flutter 3.47.8, Dart 3.13.7
const _lockChannel = MethodChannel('app/orientation_lock');

Future<void> setIpadOrientationLocked(bool locked) =>
    _lockChannel.invokeMethod<void>('setLocked', locked);

Das behält die Ausrichtung bei, die die Scene bereits hat. Es dreht nicht ins Querformat, ergänzt also setPreferredOrientations, ersetzt es aber nicht. Ich habe diese Unterklasse mit flutter build ios --simulator unter Xcode 27.1 in eine Flutter-3.47.8-App kompiliert (Deployment Target iOS 15, daher die #available-Prüfung). Das Multitasking-Verhalten habe ich nicht auf einem physischen iPad getestet, prüfen Sie es also auf echter Hardware, bevor Sie sich darauf verlassen.

Android 16 verhält sich auf großen Bildschirmen genauso

Android ist denselben Weg gegangen. Laut den Verhaltensänderungen von Android 16 ignorieren Apps, die API 36 anvisieren, screenOrientation, resizableActivity, minAspectRatio, maxAspectRatio, setRequestedOrientation() und getRequestedOrientation() auf Displays mit einer kleinsten Breite von mindestens 600 dp. Das umfasst Tablets, aufgeklappte Foldables und Desktop-Fenster. setPreferredOrientations von Flutter ist ein setRequestedOrientation()-Aufruf, und das Gradle-Plugin von Flutter 3.47.8 setzt flutter.targetSdkVersion auf 36. Eine frisch erstellte Flutter-App sperrt die Ausrichtung auf einem Tablet also nicht. Die Dokumentation von Flutter nennt diese Einschränkung inzwischen selbst.

Die Ausnahmen sind Spiele (deklariert mit android:appCategory="game"), Nutzer, die in den Seitenverhältnis-Einstellungen des Geräts das Standardverhalten der App wählen, und Bildschirme unter 600 dp Breite. Telefone sind nicht betroffen. Es gibt ein temporäres Opt-out:

<!-- android/app/src/main/AndroidManifest.xml, targetSdk 36 only -->
<activity android:name=".MainActivity" ...>
    <property
        android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
        android:value="true" />
</activity>

Google gibt an, dass das Opt-out nicht mehr gilt, sobald Sie API-Level 37 anvisieren. Betrachten Sie es als Zeit, Ihr Tablet-Layout zu korrigieren, nicht als Lösung. Dieselbe Regel traf MAUI-Apps, und der Beitrag zur MAUI-Migration auf API-Level 36 behandelt die Manifest-Seite ausführlicher.

Stolperfallen, wenn sich der Screen tatsächlich dreht

Weiterführende Lektüre

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück