Finally I managed to create a minimal reproducible example that fits into 1 file. This exception is killing me, spent entire weekend trying to find the reason.. I can't debug it with breakpoints as it is called at the beginning of it's thread (xcode window screenshot: https://d.pr/i/OPmq8Z )
What the MRE (minimal reproducible example) does:
3 seconds after I click the button the value of hVideoURL is set to remote video file path "https://...".
Setting thos value for hVideoURL makes .sheet(item: hVideoURL) appear
Sheet contains an AVPlayer that plays remote video. In some cases (sometimes in all cases, sometimes i need to reconnect physical device to get this exception) it triggers the exception:
2021-04-11 22:38:17.251165+0200 DetailedTesting[44173:3531565] *** Terminating app due to uncaught exception 'CALayerInvalidGeometry', reason: 'CALayer position contains NaN: [nan nan]. Layer: <CALayer:0x2811a80a0; position = CGPoint (2 8); bounds = CGRect (0 0; 455 4); delegate = <UIView: 0x11bf18f60; frame = (2 8; 455 4); anchorPoint = (0, 0); autoresizesSubviews = NO; layer = <CALayer: 0x2811a80a0>>; opaque = YES; allowsGroupOpacity = YES; anchorPoint = CGPoint (0 0); _swiftUI_displayListID = 11; backgroundColor = <CGColor 0x2835f2d60> [<CGColorSpace 0x2835e09c0> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 1 1 0.232 )>'
*** First throw call stack:
(0x19e95d86c 0x1b3976c50 0x19e8564a4 0x1a1d85db4 0x1a1d85ce4 0x1a55f5378 0x1a58f1e38 0x1a58efb78 0x1a58eea8c 0x1a56585f0 0x1a53b09d0 0x1a5658158 0x1a5657f0c 0x1a565793c 0x1a5657f2c 0x1a565793c 0x1a56576d0 0x1a5657580 0x1a57167f4 0x1a52ca0ac 0x1a5868434 0x1a585e2c8 0x1a59e8f90 0x1a59e8fc4 0x1a185fec4 0x1a1d7a644 0x1a1d7ab18 0x1a1d8f30c 0x1a1cd4640 0x1a1cffb08 0x1a1d00e98 0x19e8d8358 0x19e8d25c4 0x19e8d2b74 0x19e8d221c 0x1b649c784 0x1a1312ee8 0x1a131875c 0x1a58a2210 0x1a58a219c 0x1a53daf90 0x100b17b48 0x100b17be8 0x19e5926b0)
libc++abi.dylib: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'CALayerInvalidGeometry', reason: 'CALayer position contains NaN: [nan nan]. Layer: <CALayer:0x2811a80a0; position = CGPoint (2 8); bounds = CGRect (0 0; 455 4); delegate = <UIView: 0x11bf18f60; frame = (2 8; 455 4); anchorPoint = (0, 0); autoresizesSubviews = NO; layer = <CALayer: 0x2811a80a0>>; opaque = YES; allowsGroupOpacity = YES; anchorPoint = CGPoint (0 0); _swiftUI_displayListID = 11; backgroundColor = <CGColor 0x2835f2d60> [<CGColorSpace 0x2835e09c0> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 1 1 0.232 )>'
terminating with uncaught exception of type NSException
Sheet with video player in it (when exception occurs):
My thoughts:
I found that the error doesn't reoccur if i comment "VideoPlayerControlsView()", so the issue might be in my CustomSlider object located inside of VideoPlayerControlsView view.
I think it may somehow be caused by loading remote video, as the video is not loaded at the beginning, the app doesn't know the size/bounds of AVPlayer object and therefore some parent view (maybe CustomerSlider) can't be created or negative width/height are calculated..
Code (MRE):
import SwiftUI
import AVFoundation
import Foundation
struct ContentView: View {
@State var hVideoURL: String?
@State var isPaused: Bool = false
var body: some View {
Button("Let's Go!") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
print("settings isPaused to TRUE")
self.hVideoURL = "https://firebasestorage.googleapis.com/v0/b/fitma-e3043.appspot.com/o/flamelink%2Fmedia%2F1-horizontal.mov?alt=media&token=8f7dfc0f-0261-4a78-9eb0-6154ce1d9dfe"
print("[debug] hVideoURL = \(String(describing: hVideoURL))")
self.isPaused = true
}
}
.sheet(item: self.$hVideoURL, onDismiss: {
self.isPaused = false
print("resume playing main video")
}) { hVideoURLItem in
detailedVideoView(url: hVideoURLItem)
}
}
@ViewBuilder
func detailedVideoView(url: String) -> some View {
DetailedVideo(url: URL(string: url)!, isPaused: self.$isPaused)
.onAppear {
AppDelegate.orientationLock = UIInterfaceOrientationMask.landscapeLeft
UIDevice.current.setValue(UIInterfaceOrientation.landscapeLeft.rawValue, forKey: "orientation")
UINavigationController.attemptRotationToDeviceOrientation()
}
.onDisappear {
DispatchQueue.main.async {
AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
UINavigationController.attemptRotationToDeviceOrientation()
}
}
}
}
extension String: Identifiable {
public var id: String { self }
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct DetailedVideo: View {
var url: URL
@Binding var isPaused: Bool
var body: some View {
ZStack {
DetailedPlayerView(hVideoURL: url)
}
}
}
struct DetailedPlayerView: View {
// The progress through the video, as a percentage (from 0 to 1)
@State private var videoPos: Double = 0
// The duration of the video in seconds
@State private var videoDuration: Double = 0
// Whether we're currently interacting with the seek bar or doing a seek
@State private var seeking = false
private var player: AVPlayer = AVPlayer()
init(hVideoURL: URL?) {
if hVideoURL != nil {
player = AVPlayer(url: hVideoURL!)
player.isMuted = true
player.play()
} else {
print("[debug] hVideoURL is nil")
}
}
var body: some View {
ZStack {
VideoPlayerView(videoPos: $videoPos,
videoDuration: $videoDuration,
seeking: $seeking,
player: player)
.ignoresSafeArea(.all)
.frame(width: UIScreen.screenHeight, height: UIScreen.screenWidth)
VStack {
Spacer()
VideoPlayerControlsView(videoPos: $videoPos,
videoDuration: $videoDuration,
seeking: $seeking,
player: player)
.frame(width: UIScreen.screenHeight - 2*70, height: 20)
.padding(.bottom, 20)
}
}
.onDisappear {
// When this View isn't being shown anymore stop the player
self.player.replaceCurrentItem(with: nil)
}
}
}
// This is the SwiftUI view which wraps the UIKit-based PlayerUIView above
struct VideoPlayerView: UIViewRepresentable {
@Binding private(set) var videoPos: Double
@Binding private(set) var videoDuration: Double
@Binding private(set) var seeking: Bool
let player: AVPlayer
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<VideoPlayerView>) {
// This function gets called if the bindings change, which could be useful if
// you need to respond to external changes, but we don't in this example
}
func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView {
let uiView = VideoPlayerUIView(player: player,
videoPos: $videoPos,
videoDuration: $videoDuration,
seeking: $seeking
)
return uiView
}
static func dismantleUIView(_ uiView: UIView, coordinator: ()) {
guard let playerUIView = uiView as? VideoPlayerUIView else {
return
}
playerUIView.cleanUp()
}
}
class VideoPlayerUIView: UIView {
private let player: AVPlayer
private let playerLayer = AVPlayerLayer()
private let videoPos: Binding<Double>
private let videoDuration: Binding<Double>
private let seeking: Binding<Bool>
private var durationObservation: NSKeyValueObservation?
private var timeObservation: Any?
init(player: AVPlayer, videoPos: Binding<Double>, videoDuration: Binding<Double>, seeking: Binding<Bool>) {
self.player = player
self.videoDuration = videoDuration
self.videoPos = videoPos
self.seeking = seeking
super.init(frame: .zero)
backgroundColor = .lightGray
playerLayer.player = player
layer.addSublayer(playerLayer)
// Observe the duration of the player's item so we can display it
// and use it for updating the seek bar's position
durationObservation = player.currentItem?.observe(\.duration, changeHandler: { [weak self] item, change in
guard let self = self else { return }
self.videoDuration.wrappedValue = item.duration.seconds
})
// Observe the player's time periodically so we can update the seek bar's
// position as we progress through playback
timeObservation = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: nil) { [weak self] time in
guard let self = self else { return }
// If we're not seeking currently (don't want to override the slider
// position if the user is interacting)
guard !self.seeking.wrappedValue else {
return
}
// update videoPos with the new video time (as a percentage)
self.videoPos.wrappedValue = time.seconds / self.videoDuration.wrappedValue
}
}
required init?(coder: NSCoder) {
fatalError("[debug] init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
func cleanUp() {
// Remove observers we setup in init
durationObservation?.invalidate()
durationObservation = nil
if let observation = timeObservation {
player.removeTimeObserver(observation)
timeObservation = nil
}
}
}
// This is the SwiftUI view that contains the controls for the player
struct VideoPlayerControlsView : View {
@Binding private(set) var videoPos: Double
@Binding private(set) var videoDuration: Double
@Binding private(set) var seeking: Bool
@State var shouldStopPlayer: Bool = false
@State var player: AVPlayer
@State private var playerPaused = false
var body: some View {
HStack {
// Play/pause button
Button(action: togglePlayPause) {
Image(systemName: playerPaused ? "arrowtriangle.right.fill" : "pause.fill")
.foregroundColor(Color.red)
.contentShape(Rectangle())
.padding(.trailing, 10)
}
// Current video time
if videoPos.isFinite && videoPos.isCanonical && videoDuration.isFinite && videoDuration.isCanonical {
Text(Utility.formatSecondsToHMS(videoPos * videoDuration))
.foregroundColor(Color.red)
}
// Slider for seeking / showing video progress
CustomSlider(value: $videoPos, shouldStopPlayer: self.$shouldStopPlayer, range: (0, 1), knobWidth: 4) { modifiers in
ZStack {
Group {
Color(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.5799999833106995))//Color((red: 0.4, green: 0.3, blue: 1)
.opacity(0.4)
.frame(height: 4)
.modifier(modifiers.barRight)
Color.red//Color(red: 0.4, green: 0.3, blue: 1)
.frame(height: 4)
.modifier(modifiers.barLeft)
}
.cornerRadius(5)
VStack {
Image(systemName: "arrowtriangle.down.fill") // SF Symbol
.foregroundColor(Color.red)
.offset(y: -3)
}
.frame(width: 20, height: 20)
.contentShape(Rectangle())
.modifier(modifiers.knob)
}
}
.onChange(of: shouldStopPlayer) { _ in
if shouldStopPlayer == false {
print("[debug] shouldStopPlayer == false")
sliderEditingChanged(editingStarted: false)
} else {
if seeking == false {
print("[debug] shouldStopPlayer == true")
sliderEditingChanged(editingStarted: true)
}
}
}
.frame(height: 20)
// Video duration
if videoDuration.isCanonical && videoDuration.isFinite {
Text(Utility.formatSecondsToHMS(videoDuration))
.foregroundColor(Color.red)
}
}
.padding(.leading, 40)
.padding(.trailing, 40)
}
private func togglePlayPause() {
pausePlayer(!playerPaused)
}
private func pausePlayer(_ pause: Bool) {
playerPaused = pause
if playerPaused {
player.pause()
}
else {
player.play()
}
}
private func sliderEditingChanged(editingStarted: Bool) {
if editingStarted {
// Set a flag stating that we're seeking so the slider doesn't
// get updated by the periodic time observer on the player
seeking = true
pausePlayer(true)
}
// Do the seek if we're finished
if !editingStarted {
let targetTime = CMTime(seconds: videoPos * videoDuration,
preferredTimescale: 600)
player.seek(to: targetTime) { _ in
// Now the seek is finished, resume normal operation
self.seeking = false
self.pausePlayer(false)
}
}
}
}
class Utility: NSObject {
private static var timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.minute, .second]
formatter.zeroFormattingBehavior = [.pad]
return formatter
}()
static func formatSecondsToHMS(_ seconds: Double) -> String {
return timeHMSFormatter.string(from: seconds) ?? "00:00"
}
}
extension UIScreen {
static let screenWidth = UIScreen.main.bounds.size.width
static let screenHeight = UIScreen.main.bounds.size.height
static let screenSize = UIScreen.main.bounds.size
}
struct CustomSlider<Component: View>: View {
@Binding var value: Double
var range: (Double, Double)
var knobWidth: CGFloat?
let viewBuilder: (CustomSliderComponents) -> Component
@Binding var shouldStopPlayer: Bool
init(value: Binding<Double>, shouldStopPlayer: Binding<Bool>, range: (Double, Double), knobWidth: CGFloat? = nil, _ viewBuilder: @escaping (CustomSliderComponents) -> Component
) {
_value = value
_shouldStopPlayer = shouldStopPlayer
self.range = range
self.viewBuilder = viewBuilder
self.knobWidth = knobWidth
}
var body: some View {
return GeometryReader { geometry in
self.view(geometry: geometry) // function below
}
}
private func view(geometry: GeometryProxy) -> some View {
let frame = geometry.frame(in: .global)
let drag = DragGesture(minimumDistance: 0)
.onChanged { drag in
shouldStopPlayer = true
self.onDragChange(drag, frame)
}
.onEnded { drag in
shouldStopPlayer = false
//self.updatedValue = value
print("[debug] slider drag gesture ended, value = \(value)")
}
let offsetX = self.getOffsetX(frame: frame)
let knobSize = CGSize(width: knobWidth ?? frame.height, height: frame.height)
let barLeftSize = CGSize(width: CGFloat(offsetX + knobSize.width * 0.5), height: frame.height)
let barRightSize = CGSize(width: frame.width - barLeftSize.width, height: frame.height)
let modifiers = CustomSliderComponents(
barLeft: CustomSliderModifier(name: .barLeft, size: barLeftSize, offset: 0),
barRight: CustomSliderModifier(name: .barRight, size: barRightSize, offset: barLeftSize.width),
knob: CustomSliderModifier(name: .knob, size: knobSize, offset: offsetX))
return ZStack { viewBuilder(modifiers).gesture(drag) }
}
private func onDragChange(_ drag: DragGesture.Value,_ frame: CGRect) {
let width = (knob: Double(knobWidth ?? frame.size.height), view: Double(frame.size.width))
let xrange = (min: Double(0), max: Double(width.view - width.knob))
var value = Double(drag.startLocation.x + drag.translation.width) // knob center x
value -= 0.5*width.knob // offset from center to leading edge of knob
value = value > xrange.max ? xrange.max : value // limit to leading edge
value = value < xrange.min ? xrange.min : value // limit to trailing edge
value = value.convert(fromRange: (xrange.min, xrange.max), toRange: range)
//print("[debug] slider drag gesture detected, value = \(value)")
self.value = value
}
private func getOffsetX(frame: CGRect) -> CGFloat {
let width = (knob: knobWidth ?? frame.size.height, view: frame.size.width)
let xrange: (Double, Double) = (0, Double(width.view - width.knob))
let result = self.value.convert(fromRange: range, toRange: xrange)
return CGFloat(result)
}
}
extension Double {
func convert(fromRange: (Double, Double), toRange: (Double, Double)) -> Double {
// Example: if self = 1, fromRange = (0,2), toRange = (10,12) -> solution = 11
var value = self
value -= fromRange.0
value /= Double(fromRange.1 - fromRange.0)
value *= toRange.1 - toRange.0
value += toRange.0
return value
}
}
struct CustomSliderComponents {
let barLeft: CustomSliderModifier
let barRight: CustomSliderModifier
let knob: CustomSliderModifier
}
struct CustomSliderModifier: ViewModifier {
enum Name {
case barLeft
case barRight
case knob
}
let name: Name
let size: CGSize
let offset: CGFloat
func body(content: Content) -> some View {
content
.frame(width: (size.width >= 0) ? size.width : 0)
.position(x: size.width*0.5, y: size.height*0.5)
.offset(x: offset)
}
}
class AppDelegate: UIResponder, UIApplicationDelegate {
static var orientationLock = UIInterfaceOrientationMask.portrait
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
return AppDelegate.orientationLock
}
}
Any thoughts/ideas are highly appreciated!
When encountering a nan
issue, my first step is always to look for division operations.
In your code, this looks like your convert
function (part of the slider that you suspected). I'd check the ranges for valid values before doing the calculations:
func convert(fromRange: (Double, Double), toRange: (Double, Double)) -> Double {
//put a guard statement here to check that fromRange.0 isn't equal to fromRange.1 and that you won't get a 0 result. Return a default value otherwise.
var value = self
value -= fromRange.0
value /= Double(fromRange.1 - fromRange.0)
value *= toRange.1 - toRange.0
value += toRange.0
return value
}