Animations improvements

This commit is contained in:
Arkadiusz Fal
2022-08-25 19:09:55 +02:00
parent 5f50797b54
commit 08ed810b9e
17 changed files with 205 additions and 103 deletions

6
Shared/Constants.swift Normal file
View File

@@ -0,0 +1,6 @@
import Foundation
import SwiftUI
struct Constants {
static let overlayAnimation = Animation.linear(duration: 0.2)
}

7
Shared/Delay.swift Normal file
View File

@@ -0,0 +1,7 @@
import Foundation
struct Delay {
@discardableResult static func by(_ interval: TimeInterval, block: @escaping () -> Void) -> Timer {
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in block() }
}
}

View File

@@ -0,0 +1,51 @@
import Foundation
import SwiftUI
/// An animatable modifier that is used for observing animations for a given animatable value.
struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
/// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
var animatableData: Value {
didSet {
notifyCompletionIfFinished()
}
}
/// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
private var targetValue: Value
/// The completion callback which is called once the animation completes.
private var completion: () -> Void
init(observedValue: Value, completion: @escaping () -> Void) {
self.completion = completion
animatableData = observedValue
targetValue = observedValue
}
/// Verifies whether the current animation is finished and calls the completion callback if true.
private func notifyCompletionIfFinished() {
guard animatableData == targetValue else { return }
/// Dispatching is needed to take the next runloop for the completion callback.
/// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
DispatchQueue.main.async {
self.completion()
}
}
func body(content: Content) -> some View {
/// We're not really modifying the view so we can directly return the original input value.
return content
}
}
extension View {
/// Calls the completion handler whenever an animation on the given value completes.
/// - Parameters:
/// - value: The value to observe for animations.
/// - completion: The completion callback to call once the animation completes.
/// - Returns: A modified `View` instance with the observer attached.
func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: @escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
}
}

View File

@@ -157,6 +157,7 @@ struct AppTabNavigation: View {
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.id("channelVideos")
.zIndex(player.presentingPlayer ? -1 : 2)
.transition(.move(edge: .bottom))
}
}
@@ -171,6 +172,7 @@ struct AppTabNavigation: View {
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
.id("channelPlaylist")
.zIndex(player.presentingPlayer ? -1 : 1)
.transition(.move(edge: .bottom))
}
}

View File

@@ -133,7 +133,8 @@ struct ContentView: View {
@ViewBuilder var videoPlayer: some View {
if player.presentingPlayer {
playerView
.transition(.move(edge: .bottom))
.transition(.asymmetric(insertion: .identity, removal: .move(edge: .bottom)))
.zIndex(3)
} else if player.activeBackend == .appleAVPlayer {
#if os(iOS)
playerView.offset(y: UIScreen.main.bounds.height)

View File

@@ -12,7 +12,7 @@ import Foundation
wantsLayer = true
}}
override init(frame frameRect: NSRect) {
override init(frame frameRect: CGRect) {
super.init(frame: frameRect)
}

View File

@@ -5,6 +5,7 @@ import SwiftUI
struct PlayerQueueView: View {
var sidebarQueue: Bool
@Binding var fullScreen: Bool
@State private var relatedVisible = false
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
var watches: FetchedResults<Watch>
@@ -24,7 +25,7 @@ struct PlayerQueueView: View {
autoplaying
}
playingNext
if sidebarQueue {
if sidebarQueue, relatedVisible {
related
}
if saveHistory, showHistoryInPlayer {
@@ -37,7 +38,15 @@ struct PlayerQueueView: View {
.listRowInsets(EdgeInsets())
#endif
}
.onChange(of: player.currentItem) { _ in
relatedVisible = false
Delay.by(2) {
withAnimation(.easeIn(duration: 0.25)) {
self.relatedVisible = true
}
}
}
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
@@ -131,18 +140,18 @@ struct PlayerQueueView: View {
}
}
private var related: some View {
Group {
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
Section(header: Text("Related")) {
ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
.contextMenu {
VideoContextMenuView(video: video)
}
}
@ViewBuilder private var related: some View {
if let related = player.currentVideo?.related, !related.isEmpty {
Section(header: Text("Related")) {
ForEach(related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
.contextMenu {
VideoContextMenuView(video: video)
}
.id(video.videoID)
}
}
.transaction { t in t.disablesAnimations = true }
}
}

View File

@@ -124,13 +124,14 @@ struct VideoDescription: View {
components.scheme = "yattee"
if let yatteeURL = components.url {
let parser = URLParser(url: urlToOpen)
if parser.destination == .video,
let destination = parser.destination
if destination == .video,
parser.videoID == player.currentVideo?.videoID,
let time = parser.time
{
player.backend.seek(to: Double(time))
return
} else {
} else if destination != nil {
urlToOpen = yatteeURL
}
}

View File

@@ -89,11 +89,13 @@ struct VideoDetails: View {
VStack {}
}
}
.onPageWillChange { pageIndex in
if pageIndex == DetailsPage.comments.index {
comments.load()
}
}
.frame(maxWidth: detailsSize.width)
}
.onAppear {
page.update(.moveToFirst)
@@ -209,38 +211,36 @@ struct VideoDetails: View {
@State private var detailsSize = CGSize.zero
var detailsPage: some View {
Group {
VStack(alignment: .leading, spacing: 0) {
if let video = video {
VStack(spacing: 6) {
videoProperties
VStack(alignment: .leading, spacing: 0) {
if let video = video {
VStack(spacing: 6) {
videoProperties
Divider()
}
.padding(.bottom, 6)
Divider()
}
.padding(.bottom, 6)
VStack(alignment: .leading, spacing: 10) {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
VStack(alignment: .leading, spacing: 0) {
ForEach(1 ... Int.random(in: 2 ... 5), id: \.self) { _ in
Text(String(repeating: Video.fixture.description ?? "", count: Int.random(in: 1 ... 4)))
}
VStack(alignment: .leading, spacing: 10) {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
VStack(alignment: .leading, spacing: 0) {
ForEach(1 ... Int.random(in: 2 ... 5), id: \.self) { _ in
Text(String(repeating: Video.fixture.description ?? "", count: Int.random(in: 1 ... 4)))
}
.redacted(reason: .placeholder)
} else if video.description != nil, !video.description!.isEmpty {
VideoDescription(video: video, detailsSize: detailsSize)
#if os(iOS)
.padding(.bottom, fullScreenLayout ? 10 : SafeArea.insets.bottom)
#endif
} else {
Text("No description")
.foregroundColor(.secondary)
}
.redacted(reason: .placeholder)
} else if video.description != nil, !video.description!.isEmpty {
VideoDescription(video: video, detailsSize: detailsSize)
#if os(iOS)
.padding(.bottom, fullScreenLayout ? 10 : SafeArea.insets.bottom)
#endif
} else {
Text("No description")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal)
}
.padding(.horizontal)
}
var fullScreenLayout: Bool {

View File

@@ -8,7 +8,7 @@ import SwiftUI
struct VideoPlayerView: View {
#if os(iOS)
static let hiddenOffset = YatteeApp.isForPreviews ? 0 : max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
static let hiddenOffset = UIScreen.main.bounds.height
static let defaultSidebarQueueValue = UIScreen.main.bounds.width > 900 && Defaults[.playerSidebar] == .whenFits
#else
static let defaultSidebarQueueValue = Defaults[.playerSidebar] != .never
@@ -45,7 +45,7 @@ struct VideoPlayerView: View {
#if os(iOS)
@GestureState private var dragGestureState = false
@GestureState private var dragGestureOffset = CGSize.zero
@State private var viewDragOffset = 0.0
@State private var viewDragOffset = Self.hiddenOffset
@State private var orientationObserver: Any?
#endif
@@ -102,6 +102,7 @@ struct VideoPlayerView: View {
}
}
}
.animation(nil, value: player.playerSize)
.onAppear {
if player.musicMode {
player.backend.startControlsUpdates()
@@ -145,25 +146,20 @@ struct VideoPlayerView: View {
playerSize = geometry.size
}
}
#if os(iOS)
.frame(width: playerWidth.isNil ? nil : Double(playerWidth!), height: playerHeight.isNil ? nil : Double(playerHeight!))
.ignoresSafeArea(.all, edges: playerEdgesIgnoringSafeArea)
#endif
.onChange(of: geometry.size) { size in
self.playerSize = size
}
.onChange(of: fullScreenDetails) { value in
player.backend.setNeedsDrawing(!value)
}
#if os(iOS)
.frame(width: playerWidth.isNil ? nil : Double(playerWidth!), height: playerHeight.isNil ? nil : Double(playerHeight!))
.ignoresSafeArea(.all, edges: playerEdgesIgnoringSafeArea)
.onAppear {
#if os(iOS)
viewDragOffset = 0.0
configureOrientationUpdatesBasedOnAccelerometer()
viewDragOffset = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak player] in
player?.onPresentPlayer?()
player?.onPresentPlayer = nil
}
Delay.by(0.2) {
configureOrientationUpdatesBasedOnAccelerometer()
if let orientationMask = player.lockedOrientation {
Orientation.lockOrientation(
@@ -171,27 +167,37 @@ struct VideoPlayerView: View {
andRotateTo: orientationMask == .landscapeLeft ? .landscapeLeft : orientationMask == .landscapeRight ? .landscapeRight : .portrait
)
}
#endif
}
}
.onDisappear {
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
Orientation.lockOrientation(.allButUpsideDown)
}
stopOrientationUpdates()
playerControls.hideOverlays()
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
Orientation.lockOrientation(.allButUpsideDown)
}
stopOrientationUpdates()
playerControls.hideOverlays()
player.lockedOrientation = nil
#endif
player.lockedOrientation = nil
}
.onAnimationCompleted(for: viewDragOffset) {
guard !dragGestureState else { return }
if viewDragOffset == 0 {
player.onPresentPlayer?()
player.onPresentPlayer = nil
} else if viewDragOffset == Self.hiddenOffset {
player.hide(animate: false)
}
}
#endif
}
.compositingGroup()
#if os(iOS)
.offset(y: playerOffset)
.animation(.linear(duration: 0.2), value: playerOffset)
.backport
.persistentSystemOverlays(!fullScreenLayout)
.offset(y: playerOffset)
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
.backport
.persistentSystemOverlays(!fullScreenLayout)
#endif
#endif
}
@@ -276,11 +282,6 @@ struct VideoPlayerView: View {
}
#if os(iOS)
.gesture(playerControls.presentingOverlays ? nil : playerDragGesture)
.onChange(of: dragGestureState) { _ in
if !dragGestureState {
onPlayerDragGestureEnded()
}
}
#elseif os(macOS)
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
@@ -302,7 +303,6 @@ struct VideoPlayerView: View {
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: $fullScreenDetails)
#if os(iOS)
.ignoresSafeArea(.all, edges: .bottom)
.transition(.move(edge: .bottom))
#endif
.background(colorScheme == .dark ? Color.black : Color.white)
.modifier(VideoDetailsPaddingModifier(
@@ -412,7 +412,9 @@ struct VideoPlayerView: View {
#if os(iOS)
Button {
player.hide()
withAnimation(.spring(response: 0.3, dampingFraction: 0, blendDuration: 0)) {
viewDragOffset = Self.hiddenOffset
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 40))
@@ -474,16 +476,11 @@ struct VideoPlayerView: View {
!playerControls.presentingControlsOverlay else { return }
if viewDragOffset > 100 {
player.hide()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
player.backend.setNeedsDrawing(false)
player.exitFullScreen()
withAnimation(Constants.overlayAnimation) {
viewDragOffset = Self.hiddenOffset
}
viewDragOffset = Self.hiddenOffset
} else {
withAnimation(.linear(duration: 0.2)) {
withAnimation(Constants.overlayAnimation) {
viewDragOffset = 0
}
player.backend.setNeedsDrawing(true)
@@ -502,6 +499,8 @@ struct VideoPlayerView: View {
!player.playingFullScreen,
!player.playingInPictureInPicture
{
guard player.presentingPlayer else { return }
DispatchQueue.main.async {
playerControls.presentingControls = false
player.enterFullScreen(showControls: false)
@@ -532,7 +531,9 @@ struct VideoPlayerView: View {
lastOrientation = orientation
DispatchQueue.main.async {
guard Defaults[.enterFullscreenInLandscape] else {
guard Defaults[.enterFullscreenInLandscape],
player.presentingPlayer
else {
return
}

View File

@@ -45,6 +45,6 @@ struct MultiselectRow: View {
struct MultiselectRow_Previews: PreviewProvider {
static var previews: some View {
MultiselectRow(title: "Title", selected: false, action: { _ in })
MultiselectRow(title: "Title", selected: false) { _ in }
}
}

View File

@@ -88,7 +88,7 @@ struct ChannelPlaylistView: View {
ToolbarItem(placement: .navigation) {
if navigationStyle == .tab {
Button("Done") {
withAnimation {
withAnimation(Constants.overlayAnimation) {
navigation.presentingPlaylist = false
}
}

View File

@@ -84,7 +84,7 @@ struct ChannelVideosView: View {
ToolbarItem(placement: .navigation) {
if navigationStyle == .tab {
Button("Done") {
withAnimation {
withAnimation(Constants.overlayAnimation) {
navigation.presentingChannel = false
}
}