Categories
Programming

FocusedValue on tvOS

Alright, let’s set the scene with a mildly contrived example: you’ve got a list of things, and you want the surrounding View to match them. For a tvOS app, this could be swapping out the background and some title text to match the selected episode; since I don’t feel like coming up with some fake episode titles, however, we’re going to go with colors:

At first thought, you’d probably reach for @State, something akin to:

struct ColorsView: View {
	@State var selectedColor: Color
	let colors: [Color]

	var body: some View {
		VStack {
			Text(colorName(selectedColor))
			HStack {
				ForEach(colors) { color in 
					Rectangle().fill(color).focusable()
				}
			}
		}
			.background(WaveyShape().fill(selectedColor)
	}
}

Not too bad; attach on onFocus to the Rectangle() and it should work!

But… what if there’s more layers in between? Instead of a Rectangle(), you’ve got some other View in there, maybe doing some other logic and stuff.

Oof, now we’re going to need a @Binding, and – oh, what happens if the user swipes out of the rectangles and to our nav bar, can selectedColor be nil?

Happily, SwiftUI has something built out to handle basically this exact scenario: @FocusedValue. There’s some great tutorials out there on how to do this for a macOS app, which allows you to wire up the menu bar to respond to your selection, but it works just as well on tvOS.

Let’s get started:

struct FocusedColorKey: FocusedValueKey {
	typealias Value = Color
}

extension FocusedValues {
	var color: FocusedColorKey.Value? {
		get { self[FocusedColorKey.self] }
		set { self[FocusedSeriesKey.self] = newValue }
	}
}

Now we’ve got our new FocusedValue available, so let’s use it:

struct ColorsView: View {
	@FocusedValue(\.color) var selectedColor: Color?
	
	var body: some View {
		VStack {
			Text(colorName(selectedColor ?? Color.clear))
			HStack {
				ForEach(colors) { 
					ColorRectangleView(color: $0)
				}
			}
		}
			.background(WaveyShape().fill(selectedColor ?? Color.clear)
	}
}

The one big change here is that selectedColor can be nil. I’ve gone ahead and defaulted to .clear, but do what fits your use case.

Finally, we need to set the focused item:

struct ColorRectangleView: View {
	let color: Color

	var body: some View {
		Rectangle()
			.fill(color)
			.focusable()
			.focusedValue(\.color, color)
		}
	}
}

Et voila, it works!

Now, this may not seem like a huge change over doing it via @Binding, but keep in mind: @FocusedValue is a singleton. You can have every view in your app respond to this, without passing @Bindings every which way.

Categories
Programming

Playing Videos in SwiftUI

As of WWDC 2020, we have a way to play videos in SwiftUI without bailing out to UIKit with UIViewRepresentable. At first glance, it’s pretty simple, as well:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
	let videoURL: URL

	var body: some View {
		let player = AVPlayer(url: videoURL)
		VideoPlayer(player: player)
	}
}

Et voila, you’re playing a video! You can overlay content on top of the video player pretty easily:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
	let videoURL: URL

	var body: some View {
		let player = AVPlayer(url: videoURL)
		VideoPlayer(player: player) {
			Text("Watermark")
		}
	}
}

Seems like we’re good to go, no?

Well, not quite. Let’s talk memory management.

VideoPlayerView is a struct – it’s immutable. SwiftUI allows us to mutate the state of our views with user interaction using things like @State, thanks to some Compiler Magic.

Every time some aspect of the state changes, SwiftUI calls the body getter again.

Spotted the catch yet?

We’re declaring the AVPlayer instance inside the body getter. That means it gets reinitalized every time body gets called. Not the best for something that’s streaming a video file over a network.

But wait, we’ve already mentioned the Compiler Magic we can use to persist state: @State! Let’s try:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
	let videoURL: URL
	@State var player = AVPlayer(url: videoURL)

	var body: some View {
		VideoPlayer(player: player)
	}
}

Whoops. We’ve got a problem – self isn’t available during initialization, so we can’t initialize the AVPlayer like that. Alright, we’ll write our own init:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
	let videoURL: URL
	@State var player: AVPlayer

	init(videoURL: URL) {
		self.videoURL = videoURL
		self._player = State(initialValue: AVPlayer(url: videoURL))
	}

	var body: some View {
		VideoPlayer(player: player)
	}
}

(I suppose we could drop the let videoURL: URL there, since we’re using it immediately instead of needing to store it, but for consistency’s sake I’m leaving it in.)

Okay, sounds good – except, hang on, @State is only intended for use with structs, and if we peek at AVPlayer it’s a class.

Okay, no worries, that’s what @StateObject is for, one more tweak:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
	let videoURL: URL
	@StateObject var player: AVPlayer

	init(videoURL: URL) {
		self.videoURL = videoURL
		self._player = StateObject(wrappedValue: AVPlayer(url: videoURL))
	}

	var body: some View {
		VideoPlayer(player: player)
	}
}

There, we should be good to go now, right? Right?

Alas, the compiler says no. AVPlayer doesn’t conform to ObservableObject, so we’re out of luck.

Fortunately, ObservableObject is pretty easy to conform to, and we can make our own wrapper.

import SwiftUI
import AVKit
import Combine

class PlayerHolder: ObservableObject {
	let player: AVPlayer
	init(videoURL: URL) {
		player = AVPlayer(url: videoURL)
	}
}

struct VideoPlayerView: View {
	let videoURL: URL
	@StateObject var playerHolder: PlayerHolder

	init(videoURL: URL) {
		self.videoURL = videoURL
		self._player = StateObject(wrappedValue: PlayerHolder(videoURL: videoURL))
	}

	var body: some View {
		VideoPlayer(player: playerHolder.player)
	}
}

Phew. At long last, we’ve got a stable way to hold onto a single AVPlayer instance. And, as a bonus, we can do stuff with that reference:

import SwiftUI
import AVKit
import Combine

class PlayerHolder: ObservableObject {
	let player: AVPlayer
	init(videoURL: URL) {
		player = AVPlayer(url: videoURL)
	}
}

struct VideoPlayerView: View {
	let videoURL: URL
	@StateObject var playerHolder: PlayerHolder

	init(videoURL: URL) {
		self.videoURL = videoURL
		self._player = StateObject(wrappedValue: PlayerHolder(videoURL: videoURL))
	}

	var body: some View {
		VideoPlayer(player: playerHolder.player)
			.onAppear {
				playerHolder.player.play()
			}
	}
}

Will start playing the video as soon as the view opens. Similarly, you could add some Buttons, build your own UI on top of the video player, all sorts of fun.

And, from the other end, you can put more logic in the PlayerHolder, as well. Say you need some additional logic to get from a video ID to the actual URL that the AVPlayer can handle? Try something like this:

class PlayerHolder: ObservableObject {
	@Published var player: AVPlayer? = nil
	init(videoID: Video.ID) {
		NetworkLayer.shared.lookupVideoInformation(videoID) { result in 
			self.player = AVPlayer(url: result.url)
		}
	}
}

(Now, that’s not the nice, Combine-y way to do it, but I’ll leave that as an exercise for the reader. Or possibly another post.)

Categories
Programming

tvOS Carousels in SwiftUI

It’s a fairly common pattern in tvOS apps to have a carousel of items that scrolls off screen in either direction – something vaguely like this:

Image a tvOS wireframe, showing two horizontally-scrolling carousels.

Actually implementing this in SwiftUI seems like it’d be easy to do at first:

VStack {
	Text("Section Title")
	ScrollView(.horizontal) { 
		HStack {
			ForEach(items) { 
				ItemCell(item: $0)
			}
		}
	}
}

Which gets you… a reasonable amount of the way there, but misses something: ScrollView clips the contents, and you wind up looking like this:

A tvOS wireframe, showing two horizontally-scrolling carousels; both have been clipped to the wrong visual size.

Not ideal. So, what’s the fix? Padding! Padding, and ignoring safe areas.

VStack {
	Text("Section Title").padding(.horizontal, 64)
	ScrollView(.horizontal) {
		HStack {
			ForEach(items) { 
				ItemCell(item: $0)
			}
		}
			.padding(64) // allows space for 'hover' effect
			.padding(.horizontal, 128)
	}
		.padding(-64)
}
	.edgesIgnoringSafeArea(.horizontal)

The edgesIgnoringSafeArea allows the ScrollView to expand out to the actual edges of the screen, instead of staying within the (generous) safe areas of tvOS.1

That done, we put the horizontal padding back in on the contents themselves, so that land roughly where we want them. (I’m using 128 as a guess; your numbers may vary, based on the design spec; if you want it to look like The Default, you can read pull the safe area insets off UIWindow.)

Finally, we balance padding on the HStack with negative padding on the ScrollView; this provides enough space for the ‘lift’ (and drop shadow, if you’re using it) within the ScrollView, while keeping everything at the same visual size.

  1. tvOS has large safe areas because TVs are a mess in regards to useable screen area.