Video Chat Improvements

This commit is contained in:
Ilya Laktyushin 2021-04-30 22:47:13 +04:00
parent a8f2bf17a4
commit 385b035ed0
8 changed files with 188 additions and 59 deletions

View File

@ -372,6 +372,7 @@ public protocol PresentationGroupCall: class {
var incomingVideoSources: Signal<Set<String>, NoError> { get }
func makeIncomingVideoView(endpointId: String, completion: @escaping (PresentationCallVideoView?) -> Void)
func makeOutgoingVideoView(completion: @escaping (PresentationCallVideoView?) -> Void)
func loadMoreMembers(token: String)
}

View File

@ -165,8 +165,11 @@ private final class InnerActionsContainerNode: ASDisplayNode {
gesture.isEnabled = self.panSelectionGestureEnabled
}
func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize {
func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> CGSize {
var minActionsWidth: CGFloat = 250.0
if let minimalWidth = minimalWidth, minimalWidth > minActionsWidth {
minActionsWidth = minimalWidth
}
switch widthClass {
case .compact:
@ -517,10 +520,10 @@ final class ContextActionsContainerNode: ASDisplayNode {
}
var contentSize = CGSize()
let actionsSize = self.actionsNode.updateLayout(widthClass: widthClass, constrainedWidth: constrainedWidth, transition: transition)
let actionsSize = self.actionsNode.updateLayout(widthClass: widthClass, constrainedWidth: constrainedWidth, minimalWidth: nil, transition: transition)
if let additionalActionsNode = self.additionalActionsNode, let additionalShadowNode = self.additionalShadowNode {
let additionalActionsSize = additionalActionsNode.updateLayout(widthClass: widthClass, constrainedWidth: actionsSize.width, transition: transition)
let additionalActionsSize = additionalActionsNode.updateLayout(widthClass: widthClass, constrainedWidth: actionsSize.width, minimalWidth: actionsSize.width, transition: transition)
contentSize = additionalActionsSize
let bounds = CGRect(origin: CGPoint(), size: additionalActionsSize)

View File

@ -5,6 +5,7 @@ import AsyncDisplayKit
import SwiftSignalKit
import AppBundle
import SemanticStatusNode
import AnimationUI
private let labelFont = Font.regular(13.0)
@ -30,6 +31,8 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode {
}
enum Image {
case cameraOff
case cameraOn
case camera
case mute
case flipCamera
@ -63,6 +66,7 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode {
private let effectView: UIVisualEffectView
private let contentBackgroundNode: ASImageNode
private let contentNode: ASImageNode
private var animationNode: AnimationNode?
private let overlayHighlightNode: ASImageNode
private var statusNode: SemanticStatusNode?
let textNode: ImmediateTextNode
@ -179,6 +183,33 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode {
let contentBackgroundImage: UIImage? = nil
var animationName: String?
switch content.image {
case .cameraOff:
animationName = "anim_cameraoff"
case .cameraOn:
animationName = "anim_cameraon"
default:
break
}
if let animationName = animationName {
let animationFrame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
if self.animationNode == nil {
let animationNode = AnimationNode(animation: animationName, colors: nil, scale: 1.0)
self.animationNode = animationNode
self.contentContainer.insertSubnode(animationNode, aboveSubnode: self.contentNode)
}
if let animationNode = self.animationNode {
animationNode.frame = animationFrame
if previousContent == nil {
animationNode.seekToEnd()
} else if previousContent?.image != content.image {
animationNode.play()
}
}
}
let contentImage = generateImage(CGSize(width: self.largeButtonSize, height: self.largeButtonSize), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
@ -219,6 +250,8 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode {
var image: UIImage?
switch content.image {
case .cameraOff, .cameraOn:
image = nil
case .camera:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallCameraButton"), color: imageColor)
case .mute:

View File

@ -2384,6 +2384,76 @@ public final class PresentationGroupCallImpl: PresentationGroupCall {
self.participantsContext?.lowerHand()
}
public func makeOutgoingVideoView(completion: @escaping (PresentationCallVideoView?) -> Void) {
if self.videoCapturer == nil {
let videoCapturer = OngoingCallVideoCapturer()
self.videoCapturer = videoCapturer
}
self.videoCapturer?.makeOutgoingVideoView(completion: { view in
if let view = view {
let setOnFirstFrameReceived = view.setOnFirstFrameReceived
let setOnOrientationUpdated = view.setOnOrientationUpdated
let setOnIsMirroredUpdated = view.setOnIsMirroredUpdated
completion(PresentationCallVideoView(
holder: view,
view: view.view,
setOnFirstFrameReceived: { f in
setOnFirstFrameReceived(f)
},
getOrientation: { [weak view] in
if let view = view {
let mappedValue: PresentationCallVideoView.Orientation
switch view.getOrientation() {
case .rotation0:
mappedValue = .rotation0
case .rotation90:
mappedValue = .rotation90
case .rotation180:
mappedValue = .rotation180
case .rotation270:
mappedValue = .rotation270
}
return mappedValue
} else {
return .rotation0
}
},
getAspect: { [weak view] in
if let view = view {
return view.getAspect()
} else {
return 0.0
}
},
setOnOrientationUpdated: { f in
setOnOrientationUpdated { value, aspect in
let mappedValue: PresentationCallVideoView.Orientation
switch value {
case .rotation0:
mappedValue = .rotation0
case .rotation90:
mappedValue = .rotation90
case .rotation180:
mappedValue = .rotation180
case .rotation270:
mappedValue = .rotation270
}
f?(mappedValue, aspect)
}
},
setOnIsMirroredUpdated: { f in
setOnIsMirroredUpdated { value in
f?(value)
}
}
))
} else {
completion(nil)
}
})
}
public func requestVideo() {
if self.videoCapturer == nil {
let videoCapturer = OngoingCallVideoCapturer()

View File

@ -21,14 +21,16 @@ final class VoiceChatCameraPreviewController: ViewController {
private var animatedIn = false
private let shareCamera: () -> Void
private let cameraNode: GroupVideoNode
private let shareCamera: (ASDisplayNode) -> Void
private let switchCamera: () -> Void
private let shareScreen: () -> Void
private var presentationDataDisposable: Disposable?
init(context: AccountContext, shareCamera: @escaping () -> Void, switchCamera: @escaping () -> Void, shareScreen: @escaping () -> Void) {
init(context: AccountContext, cameraNode: GroupVideoNode, shareCamera: @escaping (ASDisplayNode) -> Void, switchCamera: @escaping () -> Void, shareScreen: @escaping () -> Void) {
self.context = context
self.cameraNode = cameraNode
self.shareCamera = shareCamera
self.switchCamera = switchCamera
self.shareScreen = shareScreen
@ -58,10 +60,12 @@ final class VoiceChatCameraPreviewController: ViewController {
}
override public func loadDisplayNode() {
self.displayNode = VoiceChatCameraPreviewControllerNode(context: self.context)
self.displayNode = VoiceChatCameraPreviewControllerNode(context: self.context, cameraNode: self.cameraNode)
self.controllerNode.shareCamera = { [weak self] in
self?.shareCamera()
self?.dismiss()
if let strongSelf = self {
strongSelf.shareCamera(strongSelf.cameraNode)
strongSelf.dismiss()
}
}
self.controllerNode.switchCamera = { [weak self] in
self?.switchCamera()
@ -106,6 +110,7 @@ private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, U
private let context: AccountContext
private var presentationData: PresentationData
private let cameraNode: GroupVideoNode
private let dimNode: ASDisplayNode
private let wrappingScrollNode: ASScrollNode
private let contentContainerNode: ASDisplayNode
@ -130,10 +135,12 @@ private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, U
var dismiss: (() -> Void)?
var cancel: (() -> Void)?
init(context: AccountContext) {
init(context: AccountContext, cameraNode: GroupVideoNode) {
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.cameraNode = cameraNode
self.wrappingScrollNode = ASScrollNode()
self.wrappingScrollNode.view.alwaysBounceVertical = true
self.wrappingScrollNode.view.delaysContentTouches = false
@ -201,7 +208,7 @@ private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, U
self.wrappingScrollNode.view.delegate = self
self.addSubnode(self.wrappingScrollNode)
self.wrappingScrollNode.addSubnode(self.backgroundNode)
self.wrappingScrollNode.addSubnode(self.contentContainerNode)
@ -214,6 +221,7 @@ private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, U
self.contentContainerNode.addSubnode(self.previewContainerNode)
self.previewContainerNode.addSubnode(self.cameraNode)
self.previewContainerNode.addSubnode(self.switchCameraButton)
self.switchCameraButton.view.addSubview(self.switchCameraEffectView)
self.switchCameraButton.addSubnode(self.switchCameraIconNode)
@ -368,6 +376,9 @@ private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, U
let previewSize = CGSize(width: contentFrame.width - previewInset * 2.0, height: contentHeight - 243.0 - bottomInset)
transition.updateFrame(node: self.previewContainerNode, frame: CGRect(origin: CGPoint(x: previewInset, y: 56.0), size: previewSize))
self.cameraNode.frame = CGRect(origin: CGPoint(), size: previewSize)
self.cameraNode.updateLayout(size: previewSize, isLandscape: false, transition: .immediate)
let switchCameraFrame = CGRect(x: previewSize.width - 48.0 - 16.0, y: previewSize.height - 48.0 - 16.0, width: 48.0, height: 48.0)
transition.updateFrame(node: self.switchCameraButton, frame: switchCameraFrame)
transition.updateFrame(view: self.switchCameraEffectView, frame: CGRect(origin: CGPoint(), size: switchCameraFrame.size))

View File

@ -177,8 +177,6 @@ final class GroupVideoNode: ASDisplayNode {
rotatedVideoFrame.size.height = ceil(rotatedVideoFrame.size.height)
var videoSize = rotatedVideoFrame.size
// CGSize(width: 1203, height: 677)
transition.updatePosition(layer: self.videoView.view.layer, position: rotatedVideoFrame.center)
transition.updateBounds(layer: self.videoView.view.layer, bounds: CGRect(origin: CGPoint(), size: videoSize))
@ -194,6 +192,8 @@ private final class MainVideoContainerNode: ASDisplayNode {
private let context: AccountContext
private let call: PresentationGroupCall
private var backdropVideoNode: GroupVideoNode?
private var currentVideoNode: GroupVideoNode?
private var candidateVideoNode: GroupVideoNode?
private let topCornersNode: ASImageNode
@ -291,7 +291,6 @@ private final class MainVideoContainerNode: ASDisplayNode {
strongSelf.candidateVideoNode = nil
let videoNode = GroupVideoNode(videoView: videoView)
if let currentVideoNode = strongSelf.currentVideoNode {
currentVideoNode.removeFromSupernode()
strongSelf.currentVideoNode = nil
@ -500,6 +499,12 @@ public final class VoiceChatController: ViewController {
if lhs.effectiveVideoEndpointId != rhs.effectiveVideoEndpointId {
return false
}
if lhs.hasVideo != rhs.hasVideo {
return false
}
if lhs.hasScreencast != rhs.hasScreencast {
return false
}
if lhs.activityTimestamp != rhs.activityTimestamp {
return false
}
@ -639,10 +644,6 @@ public final class VoiceChatController: ViewController {
if peerEntry.hasScreencast {
textIcon.insert(.screen)
}
if peerEntry.volume != nil {
textIcon.insert(.volume)
} else {
}
let yourText: String
if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil {
yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio
@ -676,6 +677,9 @@ public final class VoiceChatController: ViewController {
text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive)
icon = .microphone(true, UIColor(rgb: 0xff3b30))
} else {
if peerEntry.volume != nil {
textIcon.insert(.volume)
}
let volumeValue = peerEntry.volume.flatMap { $0 / 100 }
if let volume = volumeValue, volume != 100 {
text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").0, textIcon, .constructive)
@ -875,8 +879,8 @@ public final class VoiceChatController: ViewController {
private var requestedVideoSources = Set<String>()
private var videoNodes: [(String, GroupVideoNode)] = []
private var currentDominantSpeakerWithVideo: (PeerId, String)?
private var currentForcedSpeakerWithVideo: (PeerId, String)?
private var currentDominantSpeakerWithVideo: PeerId?
private var currentForcedSpeakerWithVideo: PeerId?
private var effectiveSpeakerWithVideo: (PeerId, String)?
private var updateAvatarDisposable = MetaDisposable()
@ -982,11 +986,13 @@ public final class VoiceChatController: ViewController {
self.bottomPanelBackgroundNode = ASDisplayNode()
self.bottomPanelBackgroundNode.backgroundColor = panelBackgroundColor
self.bottomPanelBackgroundNode.isUserInteractionEnabled = false
self.bottomCornersNode = ASImageNode()
self.bottomCornersNode.displaysAsynchronously = false
self.bottomCornersNode.displayWithoutProcessing = true
self.bottomCornersNode.image = cornersImage(top: false, bottom: true, dark: false)
self.bottomCornersNode.isUserInteractionEnabled = false
self.audioButton = CallControllerButtonItemNode()
self.cameraButton = CallControllerButtonItemNode()
@ -1076,12 +1082,12 @@ public final class VoiceChatController: ViewController {
let _ = self?.call.updateMuteState(peerId: peerId, isMuted: isMuted)
}, pinPeer: { [weak self] peerId, endpointId in
if let strongSelf = self {
if peerId != strongSelf.currentForcedSpeakerWithVideo?.0, let endpointId = endpointId {
strongSelf.currentForcedSpeakerWithVideo = (peerId, endpointId)
if peerId != strongSelf.currentForcedSpeakerWithVideo {
strongSelf.currentForcedSpeakerWithVideo = peerId
} else {
strongSelf.currentForcedSpeakerWithVideo = nil
}
strongSelf.updatePinnedParticipant()
strongSelf.updatePinnedParticipant(waitForFullSize: false)
var updateLayout = false
if strongSelf.effectiveSpeakerWithVideo != nil && !strongSelf.isExpanded {
@ -1415,7 +1421,7 @@ public final class VoiceChatController: ViewController {
for (endpointId, _) in strongSelf.videoNodes {
if entry.effectiveVideoEndpointId == endpointId {
items.append(.action(ContextMenuActionItem(text: strongSelf.currentForcedSpeakerWithVideo?.0 == peer.id ? strongSelf.presentationData.strings.VoiceChat_UnpinVideo : strongSelf.presentationData.strings.VoiceChat_PinVideo, icon: { theme in
items.append(.action(ContextMenuActionItem(text: strongSelf.currentForcedSpeakerWithVideo == peer.id ? strongSelf.presentationData.strings.VoiceChat_UnpinVideo : strongSelf.presentationData.strings.VoiceChat_PinVideo, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pin"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
guard let strongSelf = self else {
@ -1891,12 +1897,12 @@ public final class VoiceChatController: ViewController {
}
}
if let (peerId, endpointId, _) = maxLevelWithVideo {
/*if strongSelf.currentDominantSpeakerWithVideo?.0 != peerId || strongSelf.currentDominantSpeakerWithVideo?.1 != endpointId {
strongSelf.currentDominantSpeakerWithVideo = (peerId, endpointId)
strongSelf.call.setFullSizeVideo(endpointId: endpointId)
strongSelf.mainVideoContainerNode?.updatePeer(peer: (peerId: peerId, source: endpointId), waitForFullSize: true)
}*/
if let (peerId, _, _) = maxLevelWithVideo {
if strongSelf.currentDominantSpeakerWithVideo != peerId {
strongSelf.currentDominantSpeakerWithVideo = peerId
strongSelf.updatePinnedParticipant(waitForFullSize: true)
}
}
strongSelf.itemInteraction?.updateAudioLevels(levels)
@ -2056,13 +2062,13 @@ public final class VoiceChatController: ViewController {
if let (peerId, endpointId) = strongSelf.effectiveSpeakerWithVideo {
if !validSources.contains(endpointId) {
if peerId == strongSelf.currentForcedSpeakerWithVideo?.0 {
if peerId == strongSelf.currentForcedSpeakerWithVideo {
strongSelf.currentForcedSpeakerWithVideo = nil
}
if peerId == strongSelf.currentDominantSpeakerWithVideo?.0 {
if peerId == strongSelf.currentDominantSpeakerWithVideo {
strongSelf.currentDominantSpeakerWithVideo = nil
}
strongSelf.updatePinnedParticipant()
strongSelf.updatePinnedParticipant(waitForFullSize: false)
}
}
@ -3240,14 +3246,22 @@ public final class VoiceChatController: ViewController {
self.call.disableVideo()
self.call.disableScreencast()
} else {
let controller = VoiceChatCameraPreviewController(context: self.context, shareCamera: { [weak self] in
self?.call.requestVideo()
}, switchCamera: { [weak self] in
self?.call.switchVideoCamera()
}, shareScreen: { [weak self] in
self?.call.requestScreencast()
})
self.controller?.present(controller, in: .window(.root))
self.call.makeOutgoingVideoView { [weak self] view in
guard let strongSelf = self, let view = view else {
return
}
let cameraNode = GroupVideoNode(videoView: view)
let controller = VoiceChatCameraPreviewController(context: strongSelf.context, cameraNode: cameraNode, shareCamera: { [weak self] videoNode in
if let strongSelf = self {
strongSelf.call.requestVideo()
}
}, switchCamera: { [weak self] in
self?.call.switchVideoCamera()
}, shareScreen: { [weak self] in
self?.call.requestScreencast()
})
strongSelf.controller?.present(controller, in: .window(.root))
}
}
}
@ -3467,15 +3481,10 @@ public final class VoiceChatController: ViewController {
self.topPanelBackgroundNode.frame = CGRect(x: 0.0, y: topPanelHeight - 24.0, width: size.width, height: min(topPanelFrame.height, 24.0))
let listMaxY = listTopInset + listSize.height
var bottomOffset: CGFloat = min(0.0, bottomEdge - listMaxY)
let bottomOffset: CGFloat = min(0.0, bottomEdge - listMaxY) + layout.size.height - bottomPanelHeight
let bottomDelta = self.effectiveBottomAreaHeight - bottomAreaHeight
bottomOffset += layout.size.height - bottomPanelHeight
var bottomCornersFrame = CGRect(origin: CGPoint(x: sideInset + floorToScreenPixels((size.width - contentWidth) / 2.0), y: -50.0 + bottomOffset + bottomDelta), size: CGSize(width: contentWidth - sideInset * 2.0, height: 50.0))
let bottomCornersFrame = CGRect(origin: CGPoint(x: sideInset + floorToScreenPixels((size.width - contentWidth) / 2.0), y: -50.0 + bottomOffset + bottomDelta), size: CGSize(width: contentWidth - sideInset * 2.0, height: 50.0))
let previousBottomCornersFrame = self.bottomCornersNode.frame
if !bottomCornersFrame.equalTo(previousBottomCornersFrame) {
self.bottomCornersNode.frame = bottomCornersFrame
@ -3703,7 +3712,7 @@ public final class VoiceChatController: ViewController {
}
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .linear) : .immediate
self.cameraButton.update(size: videoButtonSize, content: CallControllerButtonItemNode.Content(appearance: normalButtonAppearance, image: .camera), text: self.presentationData.strings.VoiceChat_Video, transition: transition)
self.cameraButton.update(size: videoButtonSize, content: CallControllerButtonItemNode.Content(appearance: normalButtonAppearance, image: .cameraOff), text: self.presentationData.strings.VoiceChat_Video, transition: transition)
self.switchCameraButton.update(size: videoButtonSize, content: CallControllerButtonItemNode.Content(appearance: normalButtonAppearance, image: .flipCamera), text: "", transition: transition)
@ -4498,20 +4507,20 @@ public final class VoiceChatController: ViewController {
self.enqueueTileTransition(tileTransition)
}
private func updatePinnedParticipant() {
private func updatePinnedParticipant(waitForFullSize: Bool) {
let effectivePinnedParticipant = self.currentForcedSpeakerWithVideo ?? self.currentDominantSpeakerWithVideo
guard effectivePinnedParticipant?.0 != self.effectiveSpeakerWithVideo?.0 || effectivePinnedParticipant?.1 != self.effectiveSpeakerWithVideo?.1 else {
guard effectivePinnedParticipant != self.effectiveSpeakerWithVideo?.0 else {
return
}
if let (peerId, _) = effectivePinnedParticipant {
if let peerId = effectivePinnedParticipant {
for entry in self.currentEntries {
switch entry {
case let .peer(peer):
if peer.peer.id == peerId, let endpointId = peer.effectiveVideoEndpointId {
self.effectiveSpeakerWithVideo = (peerId, endpointId)
self.call.setFullSizeVideo(endpointId: endpointId)
self.mainVideoContainerNode?.updatePeer(peer: (peerId: peerId, endpointId: endpointId), waitForFullSize: false)
self.mainVideoContainerNode?.updatePeer(peer: (peerId: peerId, endpointId: endpointId), waitForFullSize: waitForFullSize)
}
default:
break

View File

@ -260,12 +260,10 @@ private class VoiceChatParticipantStatusNode: ASDisplayNode {
default:
break
}
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: size.width, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let iconSize = CGSize(width: 16.0, height: 16.0)
let spacing: CGFloat = 3.0
var contentSize = textLayout.size
var icons: [UIImage] = []
if hasVolume, let image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusVolume"), color: color) {
icons.append(image)
@ -276,6 +274,9 @@ private class VoiceChatParticipantStatusNode: ASDisplayNode {
if hasScreen, let image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusScreen"), color: color) {
icons.append(image)
}
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: size.width - (iconSize.width + spacing) * CGFloat(icons.count), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
var contentSize = textLayout.size
contentSize.width += (iconSize.width + spacing) * CGFloat(icons.count)
return (contentSize, { [weak self] in
@ -1162,6 +1163,7 @@ class VoiceChatParticipantItemNode: ItemListRevealOptionsItemNode {
return (layout, { [weak self] synchronousLoad, animated in
if let strongSelf = self {
var hadItem = strongSelf.layoutParams?.0 != nil
strongSelf.layoutParams = (item, params, first, last)
strongSelf.currentTitle = titleAttributedString?.string
strongSelf.wavesColor = wavesColor
@ -1262,8 +1264,8 @@ class VoiceChatParticipantItemNode: ItemListRevealOptionsItemNode {
}
let transition: ContainedViewLayoutTransition
if animated {
transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
if animated && hadItem {
transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
} else {
transition = .immediate
}

@ -1 +1 @@
Subproject commit ae6291030c8484492423c8272ad41db02509d9f7
Subproject commit 94a9c7b4e49c943d1ca108e35779739ad99d695a