Das Bild füllt den Schirm, und die Schrift bleibt darin

Vier Dinge am iPhone-Player, die zusammengehören, weil sie alle davon
handeln, wo der Bildschirm aufhört.

**Einpassen oder füllen.** `videoGravity` stand fest auf `.resizeAspect`,
und zwar in einer Zeile, die nach jedem Neuaufbau erneut läuft — eine
Wahl hätte dort also jeden Qualitätswechsel nicht überlebt. Jetzt steht
sie im Modell, und die Zeile liest sie. Umgelegt wird mit zwei Fingern
oder über einen Knopf; stufenlos wäre falsch, weil alles zwischen den
zwei Zuständen ein Bild ist, das weder ganz zu sehen ist noch den Schirm
füllt. Auf dem Mac erscheint der Knopf nicht: dort steht der
Vollbildknopf darüber, und zwei Knöpfe, die beide „grösser" bedeuten,
sind eine Zumutung.

**Untertitel im Füllmodus.** Dort reicht das Bild über die untere
Bildschirmkante hinaus, und genau dort zeichnet AVFoundation die
Untertitel. Statt sie anzuschneiden, wird die Bildebene um den
Sicherheitsrand eingerückt — rund zwanzig Punkt Bild gegen eine Zeile,
die man lesen kann. Dafür bekommt die Bildfläche echtes Layout: eine
`autoresizingMask` hält Ränder verhältnismässig, und ein fester Einzug
von zwanzig Punkten wäre im Querformat plötzlich vierzig gewesen.

**Die Bedienleiste kannte ihre Ränder nicht mehr.** Der Player bekommt
`ignoresSafeArea()` von der Wurzel und sieht danach überall null —
Schieberegler und Zeitangaben sassen im Home-Indikator, der Zurück-Pfeil
hinter der Dynamic Island, wo ihn niemand drücken konnte. Gemessen wird
jetzt eine Ebene weiter aussen, wo die Ränder noch gelten, und über die
Umgebung nach innen gereicht. Dieselbe Bauart wie der Akzent. Die Zeile
steht ganz am Ende der Kette: stünde sie vor dem Overlay, umschlösse das
Overlay sie, und ausgerechnet der Player ginge leer aus.

**Gedreht wird einmal, gesperrt wird nicht.** Die Maske blieb auf
`.landscape` stehen — wer im Bett auf der Seite lag, konnte nicht mehr
ins Hochformat drehen. Ein Film beginnt quer, weil er quer ist; danach
gehört die Entscheidung dem Nutzer. Dazu ein Drehknopf für den Fall,
dass die Ausrichtungssperre des Systems an ist, und ein Fehlerbehandler
an `requestGeometryUpdate`: eine abgelehnte Drehung ist dann eine
Einstellung und keine Panne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 17:05:44 +02:00
co-authored by Claude Opus 5
parent 1ff65e823a
commit 4576eac67c
7 changed files with 361 additions and 44 deletions
+51
View File
@@ -3555,6 +3555,40 @@
}
}
},
"player.fillVideo": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bild füllen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Fill screen"
}
}
}
},
"player.fitVideo": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bild einpassen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Fit to screen"
}
}
}
},
"player.forgetRememberedTrack": {
"extractionState": "manual",
"localizations": {
@@ -3858,6 +3892,23 @@
}
}
},
"player.rotate": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Drehen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Rotate"
}
}
}
},
"player.skipCredits": {
"extractionState": "stale",
"localizations": {
+26 -5
View File
@@ -23,18 +23,29 @@ final class OrientationLock {
}
}
/// Dreht ins Querformat und hält es dort.
/// Dreht zum Start der Wiedergabe ins Querformat — und gibt danach frei.
///
/// **Gedreht wird einmal, gesperrt wird nicht.** Vorher blieb die Maske
/// auf `.landscape` stehen, und damit liess sich im Player nicht mehr ins
/// Hochformat drehen. Ein Film beginnt quer, weil er quer ist; wer danach
/// im Bett auf der Seite liegt, soll drehen dürfen.
func lockLandscape() {
guard UIDevice.current.userInterfaceIdiom != .pad else { return }
apply(.landscape, rotatingTo: .landscape)
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.allButUpsideDown, rotatingTo: .landscape)
}
/// Gibt die Ausrichtung wieder frei und dreht zurück ins Hochformat.
func releaseLandscape() {
guard UIDevice.current.userInterfaceIdiom != .pad else { return }
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.portrait, rotatingTo: .portrait)
}
/// Dreht auf Knopfdruck, ohne die Freiheit danach zu nehmen.
func rotate(to target: UIInterfaceOrientationMask) {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.allButUpsideDown, rotatingTo: target)
}
private func apply(
_ newMask: UIInterfaceOrientationMask,
rotatingTo target: UIInterfaceOrientationMask
@@ -50,7 +61,17 @@ final class OrientationLock {
// Reihenfolge lehnt das System die Drehung ab, weil die alte Maske
// noch gilt — und das sah aus wie „dreht sich nicht richtig".
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
scene.requestGeometryUpdate(.iOS(interfaceOrientations: target))
// **Mit Fehlerbehandler.** Bei eingeschalteter Ausrichtungssperre
// lehnt das System die Drehung ab. Ohne diesen Block wirft UIKit die
// Ablehnung in die Konsole und es sieht aus, als hinge die App —
// dabei ist es eine Einstellung des Nutzers und in Ordnung. Der Film
// läuft dann eben hochkant weiter.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: target)) { fehler in
#if DEBUG
print("Drehung abgelehnt (vermutlich Ausrichtungssperre): \(fehler)")
#endif
}
}
}
+15 -3
View File
@@ -73,6 +73,11 @@ struct RootView: View {
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
/// wäre er längst null.
@State private var statusBarHeight: CGFloat = 0
/// Die vollen Sicherheitsränder des Bildschirms.
///
/// Hier gemessen und nicht im Player: der bekommt `ignoresSafeArea()` und
/// sieht danach überall null. Siehe `flixyScreenInsets`.
@State private var screenInsets = EdgeInsets()
var body: some View {
Group {
@@ -116,9 +121,12 @@ struct RootView: View {
// Hier und nicht weiter innen: ab dem `HStack` wird der obere
// Sicherheitsrand ignoriert, damit der Inhalt bis an den
// Bildschirmrand reicht. Wer danach misst, bekommt null.
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.safeAreaInsets.top
} action: { statusBarHeight = $0 }
.onGeometryChange(for: EdgeInsets.self) { proxy in
proxy.safeAreaInsets
} action: { neue in
screenInsets = neue
statusBarHeight = neue.top
}
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
// zuverlässig neu aus, ein Scene-Body nicht.
.flixyAccent(theme)
@@ -159,6 +167,10 @@ struct RootView: View {
// bleiben, die es auf dem neuen Server gar nicht gibt.
if newPhase == .ready, case .library = selection { selection = .home }
}
// **Ganz am Ende der Kette.** Die Umgebung fliesst von aussen nach
// innen: stünde die Zeile vor `.overlay`, umschlösse das Overlay sie
// und der Player bekäme den Wert nicht. Hier umschliesst sie beides.
.flixyScreenInsets(screenInsets)
.onChange(of: sizeClass) { _, _ in
// Beim Drehen oder Teilen des Fensters wechselt die Form der
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
+41 -1
View File
@@ -153,6 +153,34 @@ final class PlayerModel {
/// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde
/// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an.
let surface = PlayerLayerView()
/// Wie das Bild in die Fläche gelegt wird.
///
/// **Im Modell und nicht in der Ansicht**, aus demselben Grund wie die
/// Bildebene selbst: der Wechsel zwischen Kachel und Vollbild und jeder
/// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort,
/// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst".
var videoFit: VideoFit = .fit {
didSet { applyVideoGravity() }
}
/// Wie viel unten ausgespart bleibt, damit Untertitel sichtbar sind.
///
/// Im Füllmodus reicht das Bild über die untere Bildschirmkante hinaus —
/// und genau dort zeichnet AVFoundation die Untertitel. Statt sie
/// anzuschneiden, wird die **Bildebene** eingerückt: das kostet rund
/// zwanzig Punkt Bild und behält die Schrift. Im eingepassten Modus ist
/// der Wert null, weil dort ohnehin schwarze Balken liegen.
var subtitleBottomInset: CGFloat = 0
/// Wo das Bild aufhört.
public enum VideoFit: Sendable {
/// Ganz sichtbar, mit schwarzen Balken.
case fit
/// Bildfüllend, oben und unten beschnitten.
case fill
}
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
///
/// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil
@@ -1668,6 +1696,14 @@ final class PlayerModel {
}
/// Legt die gewählte Skalierung auf die Bildebene.
///
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
private func applyVideoGravity() {
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
}
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
///
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
@@ -1677,7 +1713,11 @@ final class PlayerModel {
guard let avEngine = engine as? AVPlayerEngine else { return }
surface.playerLayer.player = avEngine.player
surface.playerLayer.videoGravity = .resizeAspect
// **Den Zustand lesen, keine Konstante.** Hier stand fest
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
// läuft, fiel ein auf formatfüllend gestelltes Bild bei jedem
// Qualitätswechsel klammheimlich zurück.
applyVideoGravity()
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
if pictureInPicture == nil {
+120 -5
View File
@@ -22,6 +22,18 @@ struct PlayerView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(\.flixyAccent) private var accent
/// Die Sicherheitsränder des Bildschirms.
///
/// **Nicht hier gemessen, sondern gereicht.** Diese Ansicht bekommt
/// `ignoresSafeArea()` von der Wurzel und sieht danach überall null;
/// gemessen wird eine Ebene weiter aussen. Siehe `flixyScreenInsets`.
@Environment(\.flixyScreenInsets) private var screenInsets
/// Woran sich Quer- und Hochformat unterscheiden lassen.
///
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
/// Hochformat nicht. Billiger als die Szene zu befragen, und es
/// aktualisiert sich von selbst.
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Wie hoch die Fensterleiste auf dem Mac ist.
///
@@ -56,6 +68,15 @@ struct PlayerView: View {
@State private var isFullScreen = false
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
private var isMac: Bool { FlixyPlatform.isMac }
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
///
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
/// Mac dreht sich gar nicht. Bleibt das Telefon.
private var isPhone: Bool {
!isMac && UIDevice.current.userInterfaceIdiom == .phone
}
/// Ob das Gerät gerade quer liegt.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
@State private var lastPointerReveal: Date = .distantPast
@@ -86,6 +107,17 @@ struct PlayerView: View {
surface(for: model)
.contentShape(.rect)
.onTapGesture { primaryTap() }
// **Auseinander füllt, zusammen passt ein.**
//
// Kein stufenloses Zoomen: es gibt genau zwei
// sinnvolle Zustände, und alles dazwischen
// wäre ein Bild, das weder ganz zu sehen ist
// noch den Schirm füllt. Die Geste wählt also
// aus zweien aus, statt einen Faktor zu
// setzen — und der Schwellenwert liegt bewusst
// weit vom Ruhepunkt weg, damit eine wackelige
// Hand nichts umschaltet.
.gesture(zoomGesture(for: model), including: isMac ? .none : .all)
}
}
@@ -175,6 +207,11 @@ struct PlayerView: View {
.opacity(zeigt ? 1 : 0)
.allowsHitTesting(zeigt)
.padding(FlixySpacing.loose)
// Siehe `controls(for:)`: die Kopfzeile liegt sonst hinter
// der Dynamic Island, und der Zurück-Pfeil ist tot.
.padding(.top, screenInsets.top)
.padding(.leading, screenInsets.leading)
.padding(.trailing, screenInsets.trailing)
// **Unter der Fensterleiste beginnen.**
//
// Der Player deckt das ganze Fenster ab, und auf dem Mac
@@ -306,6 +343,13 @@ struct PlayerView: View {
scrubber(for: model)
}
.padding(FlixySpacing.loose)
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
// soll. Ersetzte eines das andere, klebte der Schieberegler im
// Querformat am Home-Indikator — genau dort stand er vorher.
.padding(.leading, screenInsets.leading)
.padding(.trailing, screenInsets.trailing)
.padding(.bottom, screenInsets.bottom)
.background {
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
@@ -380,6 +424,42 @@ struct PlayerView: View {
.keyboardShortcut("f", modifiers: [.command, .control])
}
// **Einpassen oder füllen — überall ausser auf dem Mac.**
//
// Dort gibt es den Vollbildknopf darüber, und zwei Knöpfe, die
// beide „grösser" bedeuten, nebeneinander sind eine Zumutung.
if !isMac {
Button {
setFit(model.videoFit == .fill ? .fit : .fill, on: model)
} label: {
Image(systemName: model.videoFit == .fill
? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
}
// **Drehen — nur am Telefon.**
//
// Ein iPad dreht sich von selbst, sobald man es dreht; ein Telefon
// im Player auch, seit die Sperre weg ist. Der Knopf ist für den
// Fall, dass die Ausrichtungssperre des Systems an ist — dann
// dreht das Gerät nicht mehr von allein, und das ist meistens
// genau dann, wenn man liegt.
if isPhone {
Button {
OrientationLock.shared.rotate(to: isLandscape ? .portrait : .landscape)
revealControls()
} label: {
Image(systemName: isLandscape ? "iphone" : "iphone.landscape")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.rotate"))
}
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
@@ -589,9 +669,11 @@ struct PlayerView: View {
}
}
.padding(.horizontal, FlixySpacing.section)
.padding(.leading, screenInsets.leading)
.padding(.trailing, screenInsets.trailing)
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// die Bedienelemente eingeblendet sind.
.padding(.bottom, showsControls ? 110 : FlixySpacing.section)
.padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + screenInsets.bottom)
}
.animation(.snappy, value: model.activeMarker?.id)
.animation(.snappy, value: model.hasReachedEnd)
@@ -739,18 +821,51 @@ struct PlayerView: View {
let fläche = VideoSurface(
engine: model.engine,
surface: model.surface,
isActive: playback.presentation == .full
isActive: playback.presentation == .full,
// **Nur im Füllmodus.** Eingepasst liegen unten ohnehin schwarze
// Balken, in denen der Untertitel Platz hat; dort etwas
// auszusparen nähme Bild, ohne etwas zu retten.
bottomInset: model.videoFit == .fill ? screenInsets.bottom : 0
)
if let breite = model.statistics?.width,
let höhe = model.statistics?.height,
breite > 0, höhe > 0 {
// **Im Füllmodus kein Seitenverhältnis.** Die Fläche soll den Schirm
// decken — das Einpassen ist genau das, was hier abgeschaltet wird.
// Dass der schwarze Rand dann keine Klicks mehr abfängt, ist kein
// Verlust: es gibt keinen.
if model.videoFit == .fill {
fläche
} else if let breite = model.statistics?.width,
let höhe = model.statistics?.height,
breite > 0, höhe > 0 {
fläche.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit)
} else {
fläche
}
}
/// Die Zweifingergeste, die zwischen einpassen und füllen umlegt.
private func zoomGesture(for model: PlayerModel) -> some Gesture {
MagnifyGesture(minimumScaleDelta: 0.1)
.onEnded { wert in
if wert.magnification > 1.1 {
setFit(.fill, on: model)
} else if wert.magnification < 0.9 {
setFit(.fit, on: model)
}
}
}
/// Legt die Skalierung um und zeigt dabei die Bedienelemente.
///
/// Das Einblenden gehört dazu: der Knopf wechselt sein Symbol, und wer
/// nach der Geste nicht sieht, welcher Zustand nun gilt, probiert sie
/// ein zweites Mal.
private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) {
guard model.videoFit != neu else { return }
model.videoFit = neu
revealControls()
}
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
if case .playing = model.phase { return true }
return false
+80 -30
View File
@@ -52,18 +52,24 @@ struct VideoSurface: View {
/// nicht, wer sie bekommen **soll**. Das steht hier.
var isActive = true
/// Wie viel unten frei bleibt, damit Untertitel nicht abgeschnitten werden.
///
/// Siehe `PlayerModel.subtitleBottomInset`. Hier steht nur die Zahl; was
/// sie bedeutet, entscheidet das Modell.
var bottomInset: CGFloat = 0
var body: some View {
#if canImport(FlixyVLC)
if let vlcEngine = engine as? VLCPlayerEngine {
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive)
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset)
} else if engine is AVPlayerEngine {
SurfaceContainer(view: surface, isActive: isActive)
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
#else
if engine is AVPlayerEngine {
SurfaceContainer(view: surface, isActive: isActive)
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
@@ -130,58 +136,102 @@ final class PlayerLayerView: NSView {
private struct SurfaceContainer: UIViewRepresentable {
let view: PlatformView
let isActive: Bool
let bottomInset: CGFloat
func makeUIView(context: Context) -> PlatformView {
let host = UIView()
func makeUIView(context: Context) -> SurfaceHostView {
let host = SurfaceHostView()
host.backgroundColor = .black
if isActive { adopt(view, into: host) }
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
if isActive { host.adopt(view) }
return host
}
func updateUIView(_ host: PlatformView, context: Context) {
func updateUIView(_ host: SurfaceHostView, context: Context) {
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
// Wer nicht dran ist, greift auch nicht zu. Ohne diese Zeile holen
// sich beide Ansichten dieselbe Fläche, und die Reihenfolge
// entscheidet — mal richtig, mal schwarz.
guard isActive, view.superview !== host else { return }
adopt(view, into: host)
host.adopt(view)
}
}
#else
private struct SurfaceContainer: NSViewRepresentable {
let view: PlatformView
let isActive: Bool
let bottomInset: CGFloat
func makeNSView(context: Context) -> PlatformView {
let host = NSView()
func makeNSView(context: Context) -> SurfaceHostView {
let host = SurfaceHostView()
host.wantsLayer = true
host.layer?.backgroundColor = NSColor.black.cgColor
if isActive { adopt(view, into: host) }
host.bottomInset = bottomInset
if isActive { host.adopt(view) }
return host
}
func updateNSView(_ host: PlatformView, context: Context) {
func updateNSView(_ host: SurfaceHostView, context: Context) {
host.bottomInset = bottomInset
guard isActive, view.superview !== host else { return }
adopt(view, into: host)
host.adopt(view)
}
}
#endif
/// Hängt die Fläche in ihre Hülle.
/// Die Hülle, die der Bildfläche ihren Platz zuweist.
///
/// Sie wird **nicht** unmittelbar an SwiftUI zurückgegeben, sondern in eine
/// eigene Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es
/// Kachel und Vollbild kurz gleichzeitig gibt: eine Ansicht hat nur einen
/// Vorgesetzten, und wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie
/// unmittelbar heraus, nahm das Abräumen der einen der anderen das Bild weg.
private func adopt(_ view: PlatformView, into host: PlatformView) {
#if canImport(UIKit)
view.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
#else
view.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds
view.autoresizingMask = [.width, .height]
#endif
host.addSubview(view)
/// **Warum nicht mehr `autoresizingMask`.** Bisher bekam die Fläche die
/// Grösse der Hülle und eine Maske, die sie mitwachsen liess. Eine Maske hält
/// Ränder aber **verhältnismässig**: ein Einzug von zwanzig Punkten wüchse
/// beim Drehen mit und wäre im Querformat plötzlich vierzig. Ein fester Rand
/// verlangt echtes Layout — und das sind genau diese vier Zeilen.
#if canImport(UIKit)
final class SurfaceHostView: UIView {
/// Was ringsum frei bleibt. Heute nur unten, siehe `VideoSurface`.
var contentInset: UIEdgeInsets = .zero {
didSet {
guard contentInset != oldValue else { return }
setNeedsLayout()
}
}
/// Hängt die Fläche ein — ohne Maske, die Grösse kommt aus `layoutSubviews`.
func adopt(_ view: UIView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = []
addSubview(view)
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
subviews.first?.frame = bounds.inset(by: contentInset)
}
}
#else
final class SurfaceHostView: NSView {
var bottomInset: CGFloat = 0 {
didSet {
guard bottomInset != oldValue else { return }
needsLayout = true
}
}
func adopt(_ view: NSView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = []
addSubview(view)
needsLayout = true
}
override func layout() {
super.layout()
// Auf dem Mac liegt der Ursprung unten links: einzurücken heisst
// hier, den Ursprung anzuheben und die Höhe zu kürzen.
var rahmen = bounds
rahmen.origin.y += bottomInset
rahmen.size.height -= bottomInset
subviews.first?.frame = rahmen
}
}
#endif