import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SyncCore import SwiftSignalKit import AccountContext import TelegramPresentationData import SolidRoundedButtonNode import PresentationDataUtils import UIKitRuntimeUtils import ReplayKit private let accentColor: UIColor = UIColor(rgb: 0x007aff) protocol PreviewVideoNode: ASDisplayNode { var ready: Signal { get } func flip(withBackground: Bool) func updateIsBlurred(isBlurred: Bool, light: Bool, animated: Bool) func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) } final class VoiceChatCameraPreviewController: ViewController { private var controllerNode: VoiceChatCameraPreviewControllerNode { return self.displayNode as! VoiceChatCameraPreviewControllerNode } private let sharedContext: SharedAccountContext private var animatedIn = false private let cameraNode: PreviewVideoNode private let shareCamera: (ASDisplayNode, Bool) -> Void private let switchCamera: () -> Void private var presentationDataDisposable: Disposable? init(sharedContext: SharedAccountContext, cameraNode: PreviewVideoNode, shareCamera: @escaping (ASDisplayNode, Bool) -> Void, switchCamera: @escaping () -> Void) { self.sharedContext = sharedContext self.cameraNode = cameraNode self.shareCamera = shareCamera self.switchCamera = switchCamera super.init(navigationBarPresentationData: nil) self.statusBar.statusBarStyle = .Ignore self.blocksBackgroundWhenInOverlay = true self.presentationDataDisposable = (sharedContext.presentationData |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self { strongSelf.controllerNode.updatePresentationData(presentationData) } }) self.statusBar.statusBarStyle = .Ignore } required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.presentationDataDisposable?.dispose() } override public func loadDisplayNode() { self.displayNode = VoiceChatCameraPreviewControllerNode(controller: self, sharedContext: self.sharedContext, cameraNode: self.cameraNode) self.controllerNode.shareCamera = { [weak self] unmuted in if let strongSelf = self { strongSelf.shareCamera(strongSelf.cameraNode, unmuted) strongSelf.dismiss() } } self.controllerNode.switchCamera = { [weak self] in self?.switchCamera() self?.cameraNode.flip(withBackground: false) } self.controllerNode.dismiss = { [weak self] in self?.presentingViewController?.dismiss(animated: false, completion: nil) } self.controllerNode.cancel = { [weak self] in self?.dismiss() } } override public func loadView() { super.loadView() } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if !self.animatedIn { self.animatedIn = true self.controllerNode.animateIn() } } override public func dismiss(completion: (() -> Void)? = nil) { self.controllerNode.animateOut(completion: completion) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) } } private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, UIScrollViewDelegate { private weak var controller: VoiceChatCameraPreviewController? private let sharedContext: SharedAccountContext private var presentationData: PresentationData private let cameraNode: PreviewVideoNode private let dimNode: ASDisplayNode private let wrappingScrollNode: ASScrollNode private let contentContainerNode: ASDisplayNode private let effectNode: ASDisplayNode private let backgroundNode: ASDisplayNode private let contentBackgroundNode: ASDisplayNode private let titleNode: ASTextNode private let previewContainerNode: ASDisplayNode private let shimmerNode: ShimmerEffectForegroundNode private let doneButton: SolidRoundedButtonNode private var broadcastPickerView: UIView? private let cancelButton: SolidRoundedButtonNode private let microphoneButton: HighlightTrackingButtonNode private let microphoneEffectView: UIVisualEffectView private let microphoneIconNode: VoiceChatMicrophoneNode private let placeholderTextNode: ImmediateTextNode private let placeholderIconNode: ASImageNode private let tabsNode: TabsSegmentedControlNode private var selectedTabIndex: Int = 0 private var containerLayout: (ContainerViewLayout, CGFloat)? private var applicationStateDisposable: Disposable? private let hapticFeedback = HapticFeedback() private let readyDisposable = MetaDisposable() var shareCamera: ((Bool) -> Void)? var switchCamera: (() -> Void)? var dismiss: (() -> Void)? var cancel: (() -> Void)? init(controller: VoiceChatCameraPreviewController, sharedContext: SharedAccountContext, cameraNode: PreviewVideoNode) { self.controller = controller self.sharedContext = sharedContext self.presentationData = sharedContext.currentPresentationData.with { $0 } self.cameraNode = cameraNode self.wrappingScrollNode = ASScrollNode() self.wrappingScrollNode.view.alwaysBounceVertical = true self.wrappingScrollNode.view.delaysContentTouches = false self.wrappingScrollNode.view.canCancelContentTouches = true self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) self.contentContainerNode = ASDisplayNode() self.contentContainerNode.isOpaque = false self.backgroundNode = ASDisplayNode() self.backgroundNode.clipsToBounds = true self.backgroundNode.cornerRadius = 16.0 let backgroundColor = UIColor(rgb: 0x1c1c1e) let textColor: UIColor = .white let buttonColor: UIColor = UIColor(rgb: 0x2b2b2f) let buttonTextColor: UIColor = .white let blurStyle: UIBlurEffect.Style = .dark self.effectNode = ASDisplayNode(viewBlock: { return UIVisualEffectView(effect: UIBlurEffect(style: blurStyle)) }) self.contentBackgroundNode = ASDisplayNode() self.contentBackgroundNode.backgroundColor = backgroundColor let title = self.presentationData.strings.VoiceChat_VideoPreviewTitle self.titleNode = ASTextNode() self.titleNode.attributedText = NSAttributedString(string: title, font: Font.bold(17.0), textColor: textColor) self.doneButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: accentColor, foregroundColor: .white), font: .bold, height: 52.0, cornerRadius: 11.0, gloss: false) self.doneButton.title = self.presentationData.strings.VoiceChat_VideoPreviewContinue if #available(iOS 12.0, *) { let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 50, height: 52.0)) broadcastPickerView.alpha = 0.02 broadcastPickerView.isHidden = true broadcastPickerView.preferredExtension = "\(self.sharedContext.applicationBindings.appBundleId).BroadcastUpload" broadcastPickerView.showsMicrophoneButton = false self.broadcastPickerView = broadcastPickerView } self.cancelButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: buttonColor, foregroundColor: buttonTextColor), font: .regular, height: 52.0, cornerRadius: 11.0, gloss: false) self.cancelButton.title = self.presentationData.strings.Common_Cancel self.previewContainerNode = ASDisplayNode() self.previewContainerNode.clipsToBounds = true self.previewContainerNode.cornerRadius = 11.0 self.previewContainerNode.backgroundColor = UIColor(rgb: 0x2b2b2f) self.shimmerNode = ShimmerEffectForegroundNode(size: 200.0) self.previewContainerNode.addSubnode(self.shimmerNode) self.microphoneButton = HighlightTrackingButtonNode() self.microphoneButton.isSelected = true self.microphoneEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) self.microphoneEffectView.clipsToBounds = true self.microphoneEffectView.layer.cornerRadius = 24.0 self.microphoneEffectView.isUserInteractionEnabled = false self.microphoneIconNode = VoiceChatMicrophoneNode() // self.microphoneIconNode.alpha = 0.75 self.microphoneIconNode.update(state: .init(muted: false, filled: true, color: .white), animated: false) self.tabsNode = TabsSegmentedControlNode(items: [TabsSegmentedControlNode.Item(title: "Front Camera"), TabsSegmentedControlNode.Item(title: "Back Camera"), TabsSegmentedControlNode.Item(title: "Share Screen")], selectedIndex: 0) self.placeholderTextNode = ImmediateTextNode() self.placeholderTextNode.alpha = 0.0 self.placeholderTextNode.maximumNumberOfLines = 3 self.placeholderTextNode.textAlignment = .center self.placeholderIconNode = ASImageNode() self.placeholderIconNode.alpha = 0.0 self.placeholderIconNode.contentMode = .scaleAspectFit self.placeholderIconNode.displaysAsynchronously = false super.init() self.backgroundColor = nil self.isOpaque = false self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) self.addSubnode(self.dimNode) self.wrappingScrollNode.view.delegate = self self.addSubnode(self.wrappingScrollNode) self.wrappingScrollNode.addSubnode(self.backgroundNode) self.wrappingScrollNode.addSubnode(self.contentContainerNode) self.backgroundNode.addSubnode(self.effectNode) self.backgroundNode.addSubnode(self.contentBackgroundNode) self.contentContainerNode.addSubnode(self.titleNode) self.contentContainerNode.addSubnode(self.doneButton) if let broadcastPickerView = self.broadcastPickerView { self.contentContainerNode.view.addSubview(broadcastPickerView) } self.contentContainerNode.addSubnode(self.cancelButton) self.contentContainerNode.addSubnode(self.previewContainerNode) self.previewContainerNode.addSubnode(self.cameraNode) self.previewContainerNode.addSubnode(self.placeholderIconNode) self.previewContainerNode.addSubnode(self.placeholderTextNode) if self.cameraNode is GroupVideoNode { self.previewContainerNode.addSubnode(self.microphoneButton) self.microphoneButton.view.addSubview(self.microphoneEffectView) self.microphoneButton.addSubnode(self.microphoneIconNode) } self.previewContainerNode.addSubnode(self.tabsNode) self.tabsNode.selectedIndexChanged = { [weak self] index in if let strongSelf = self { if (index == 0 && strongSelf.selectedTabIndex == 1) || (index == 1 && strongSelf.selectedTabIndex == 0) { strongSelf.switchCamera?() } if index == 2 && [0, 1].contains(strongSelf.selectedTabIndex) { strongSelf.broadcastPickerView?.isHidden = false strongSelf.cameraNode.updateIsBlurred(isBlurred: true, light: false, animated: true) let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 1.0) transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 1.0) } else if [0, 1].contains(index) && strongSelf.selectedTabIndex == 2 { strongSelf.broadcastPickerView?.isHidden = true strongSelf.cameraNode.updateIsBlurred(isBlurred: false, light: false, animated: true) let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 0.0) transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 0.0) } strongSelf.selectedTabIndex = index } } self.doneButton.pressed = { [weak self] in if let strongSelf = self { strongSelf.shareCamera?(strongSelf.microphoneButton.isSelected) } } self.cancelButton.pressed = { [weak self] in if let strongSelf = self { strongSelf.cancel?() } } self.microphoneButton.addTarget(self, action: #selector(self.microphonePressed), forControlEvents: .touchUpInside) self.microphoneButton.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .spring) transition.updateSublayerTransformScale(node: strongSelf.microphoneButton, scale: 0.9) } else { let transition: ContainedViewLayoutTransition = .animated(duration: 0.5, curve: .spring) transition.updateSublayerTransformScale(node: strongSelf.microphoneButton, scale: 1.0) } } } self.readyDisposable.set(self.cameraNode.ready.start(next: { [weak self] ready in if let strongSelf = self, ready { Queue.mainQueue().after(0.07) { strongSelf.shimmerNode.alpha = 0.0 strongSelf.shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) } } })) } deinit { self.readyDisposable.dispose() self.applicationStateDisposable?.dispose() } @objc private func microphonePressed() { self.hapticFeedback.impact(.light) self.microphoneButton.isSelected = !self.microphoneButton.isSelected self.microphoneIconNode.update(state: .init(muted: !self.microphoneButton.isSelected, filled: true, color: .white), animated: true) } func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData } override func didLoad() { super.didLoad() if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never } } @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.cancel?() } } func animateIn() { self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY let dimPosition = self.dimNode.layer.position let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) let targetBounds = self.bounds self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset) self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset) transition.animateView({ self.bounds = targetBounds self.dimNode.position = dimPosition }) self.applicationStateDisposable = (self.sharedContext.applicationBindings.applicationIsActive |> filter { !$0 } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.controller?.dismiss() }) } func animateOut(completion: (() -> Void)? = nil) { var dimCompleted = false var offsetCompleted = false let internalCompletion: () -> Void = { [weak self] in if let strongSelf = self, dimCompleted && offsetCompleted { strongSelf.dismiss?() } completion?() } self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in dimCompleted = true internalCompletion() }) let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY let dimPosition = self.dimNode.layer.position self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in offsetCompleted = true internalCompletion() }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.bounds.contains(point) { if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) { return self.dimNode.view } } return super.hitTest(point, with: event) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { let contentOffset = scrollView.contentOffset let additionalTopHeight = max(0.0, -contentOffset.y) if additionalTopHeight >= 30.0 { self.cancel?() } } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { self.containerLayout = (layout, navigationBarHeight) let isLandscape: Bool if layout.size.width > layout.size.height { isLandscape = true } else { isLandscape = false } let isTablet: Bool if case .regular = layout.metrics.widthClass { isTablet = true } else { isTablet = false } var insets = layout.insets(options: [.statusBar, .input]) let cleanInsets = layout.insets(options: [.statusBar]) insets.top = max(10.0, insets.top) var buttonOffset: CGFloat = 60.0 let bottomInset: CGFloat = isTablet ? 31.0 : 10.0 + cleanInsets.bottom let titleHeight: CGFloat = 54.0 var contentHeight = titleHeight + bottomInset + 52.0 + 17.0 let innerContentHeight: CGFloat = layout.size.height - contentHeight - 160.0 var width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: layout.safeInsets.left) if isLandscape { if isTablet { width = 870.0 contentHeight = 690.0 } else { contentHeight = layout.size.height width = layout.size.width } } else { if isTablet { width = 600.0 contentHeight = 960.0 } else { contentHeight = titleHeight + bottomInset + 52.0 + 17.0 + innerContentHeight + buttonOffset } } let previewInset: CGFloat = 16.0 let sideInset = floor((layout.size.width - width) / 2.0) let contentFrame: CGRect if isTablet { contentFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((layout.size.height - contentHeight) / 2.0)), size: CGSize(width: width, height: contentHeight)) } else { contentFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentHeight), size: CGSize(width: width, height: contentHeight)) } var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.width, height: contentFrame.height)) if !isTablet { backgroundFrame.size.height += 2000.0 } if backgroundFrame.minY < contentFrame.minY { backgroundFrame.origin.y = contentFrame.minY } transition.updateAlpha(node: self.titleNode, alpha: isLandscape && !isTablet ? 0.0 : 1.0) transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) transition.updateFrame(node: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) let titleSize = self.titleNode.measure(CGSize(width: width, height: titleHeight)) let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 18.0), size: titleSize) transition.updateFrame(node: self.titleNode, frame: titleFrame) var previewSize: CGSize var previewFrame: CGRect if isLandscape { let previewHeight = contentHeight - 21.0 - 52.0 - 10.0 previewSize = CGSize(width: min(contentFrame.width - layout.safeInsets.left - layout.safeInsets.right, previewHeight * 1.7778), height: previewHeight) if isTablet { previewSize.width -= previewInset * 2.0 previewSize.height -= 46.0 } previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentFrame.width - previewSize.width) / 2.0), y: 0.0), size: previewSize) if isTablet { previewFrame.origin.y += 56.0 } } else { previewSize = CGSize(width: contentFrame.width - previewInset * 2.0, height: contentHeight - 243.0 - bottomInset + (120.0 - buttonOffset)) if isTablet { previewSize.height += 17.0 } previewFrame = CGRect(origin: CGPoint(x: previewInset, y: 56.0), size: previewSize) } transition.updateFrame(node: self.previewContainerNode, frame: previewFrame) transition.updateFrame(node: self.shimmerNode, frame: CGRect(origin: CGPoint(), size: previewFrame.size)) self.shimmerNode.update(foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.07)) self.shimmerNode.updateAbsoluteRect(previewFrame, within: layout.size) self.cameraNode.frame = CGRect(origin: CGPoint(), size: previewSize) self.cameraNode.updateLayout(size: previewSize, layoutMode: isLandscape ? .fillHorizontal : .fillVertical, transition: .immediate) let microphoneFrame = CGRect(x: 8.0, y: previewSize.height - 48.0 - 8.0 - 48.0, width: 48.0, height: 48.0) transition.updateFrame(node: self.microphoneButton, frame: microphoneFrame) transition.updateFrame(view: self.microphoneEffectView, frame: CGRect(origin: CGPoint(), size: microphoneFrame.size)) transition.updateFrameAsPositionAndBounds(node: self.microphoneIconNode, frame: CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: microphoneFrame.size).insetBy(dx: 6.0, dy: 6.0)) self.microphoneIconNode.transform = CATransform3DMakeScale(1.2, 1.2, 1.0) let tabsFrame = CGRect(x: 8.0, y: previewSize.height - 40.0 - 8.0, width: previewSize.width - 16.0, height: 40.0) self.tabsNode.updateLayout(size: tabsFrame.size, transition: transition) transition.updateFrame(node: self.tabsNode, frame: tabsFrame) self.placeholderTextNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_VideoPreviewShareScreenInfo, font: Font.semibold(14.0), textColor: .white) self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white) let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: previewSize.width - 80.0, height: 100.0)) transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) + 10.0), size: placeholderTextSize)) if let imageSize = self.placeholderIconNode.image?.size { transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - imageSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) - imageSize.height - 8.0), size: imageSize)) } if isLandscape { var buttonsCount: Int = 2 let buttonInset: CGFloat = 6.0 var leftButtonInset = buttonInset let availableWidth: CGFloat if isTablet { availableWidth = contentFrame.width - layout.safeInsets.left - layout.safeInsets.right - previewInset * 2.0 leftButtonInset += previewInset } else { availableWidth = contentFrame.width - layout.safeInsets.left - layout.safeInsets.right } let buttonWidth = floorToScreenPixels((availableWidth - CGFloat(buttonsCount + 1) * buttonInset) / CGFloat(buttonsCount)) let cameraButtonHeight = self.doneButton.updateLayout(width: buttonWidth, transition: transition) let cancelButtonHeight = self.cancelButton.updateLayout(width: buttonWidth, transition: transition) transition.updateFrame(node: self.cancelButton, frame: CGRect(x: layout.safeInsets.left + leftButtonInset, y: previewFrame.maxY + 10.0, width: buttonWidth, height: cancelButtonHeight)) transition.updateFrame(node: self.doneButton, frame: CGRect(x: layout.safeInsets.left + leftButtonInset + buttonWidth + buttonInset, y: previewFrame.maxY + 10.0, width: buttonWidth, height: cameraButtonHeight)) self.broadcastPickerView?.frame = self.doneButton.frame } else { let bottomInset = isTablet ? 21.0 : insets.bottom + 16.0 let buttonInset: CGFloat = 16.0 let cameraButtonHeight = self.doneButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition) transition.updateFrame(node: self.doneButton, frame: CGRect(x: buttonInset, y: contentHeight - cameraButtonHeight - bottomInset - buttonOffset, width: contentFrame.width, height: cameraButtonHeight)) self.broadcastPickerView?.frame = self.doneButton.frame let cancelButtonHeight = self.cancelButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition) transition.updateFrame(node: self.cancelButton, frame: CGRect(x: buttonInset, y: contentHeight - cancelButtonHeight - bottomInset, width: contentFrame.width, height: cancelButtonHeight)) } transition.updateFrame(node: self.contentContainerNode, frame: contentFrame) } } private let textFont = Font.medium(14.0) class TabsSegmentedControlNode: ASDisplayNode, UIGestureRecognizerDelegate { struct Item: Equatable { public let title: String public init(title: String) { self.title = title } } private var blurEffectView: UIVisualEffectView? private var vibrancyEffectView: UIVisualEffectView? private let selectionNode: ASDisplayNode private var itemNodes: [HighlightTrackingButtonNode] private var highlightedItemNodes: [HighlightTrackingButtonNode] private var validLayout: CGSize? private var _items: [Item] private var _selectedIndex: Int = 0 public var selectedIndex: Int { get { return self._selectedIndex } set { guard newValue != self._selectedIndex else { return } self._selectedIndex = newValue if let size = self.validLayout { self.updateLayout(size: size, transition: .immediate) } } } public func setSelectedIndex(_ index: Int, animated: Bool) { guard index != self._selectedIndex else { return } self._selectedIndex = index if let size = self.validLayout { self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .easeInOut)) } } public var selectedIndexChanged: (Int) -> Void = { _ in } private var gestureRecognizer: UIPanGestureRecognizer? private var gestureSelectedIndex: Int? public init(items: [Item], selectedIndex: Int) { self._items = items self._selectedIndex = selectedIndex self.selectionNode = ASDisplayNode() self.selectionNode.clipsToBounds = true self.selectionNode.backgroundColor = .black self.selectionNode.alpha = 0.75 self.itemNodes = items.map { item in let itemNode = HighlightTrackingButtonNode() itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) itemNode.imageNode.isHidden = true itemNode.titleNode.maximumNumberOfLines = 1 itemNode.titleNode.truncationMode = .byTruncatingTail itemNode.titleNode.alpha = 0.75 itemNode.accessibilityLabel = item.title itemNode.accessibilityTraits = [.button] itemNode.setTitle(item.title, with: textFont, with: .black, for: .normal) return itemNode } self.highlightedItemNodes = items.map { item in let itemNode = HighlightTrackingButtonNode() itemNode.isUserInteractionEnabled = false itemNode.isHidden = true itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) itemNode.imageNode.isHidden = true itemNode.titleNode.maximumNumberOfLines = 1 itemNode.titleNode.truncationMode = .byTruncatingTail itemNode.setTitle(item.title, with: textFont, with: .white, for: .normal) return itemNode } super.init() self.clipsToBounds = true if #available(iOS 13.0, *) { self.layer.cornerCurve = .continuous self.selectionNode.layer.cornerCurve = .continuous } self.setupButtons() } override func didLoad() { super.didLoad() self.view.disablesInteractiveTransitionGestureRecognizer = true let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) gestureRecognizer.delegate = self self.view.addGestureRecognizer(gestureRecognizer) self.gestureRecognizer = gestureRecognizer let blurEffect = UIBlurEffect(style: .light) let blurEffectView = UIVisualEffectView(effect: blurEffect) self.blurEffectView = blurEffectView self.view.addSubview(blurEffectView) let vibrancyEffect: UIVibrancyEffect if #available(iOS 13.0, *) { vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .label) } else { vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect) } let vibrancyEffectView = UIVisualEffectView(effect: vibrancyEffect) self.vibrancyEffectView = vibrancyEffectView blurEffectView.contentView.addSubview(vibrancyEffectView) self.itemNodes.forEach(vibrancyEffectView.contentView.addSubnode(_:)) vibrancyEffectView.contentView.addSubnode(self.selectionNode) self.highlightedItemNodes.forEach(self.addSubnode(_:)) } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { self.validLayout = size let bounds = CGRect(origin: CGPoint(), size: size) self.cornerRadius = size.height / 2.0 if let blurEffectView = self.blurEffectView { transition.updateFrame(view: blurEffectView, frame: bounds) } if let vibrancyEffectView = self.vibrancyEffectView { transition.updateFrame(view: vibrancyEffectView, frame: bounds) } let selectedIndex: Int if let gestureSelectedIndex = self.gestureSelectedIndex { selectedIndex = gestureSelectedIndex } else { selectedIndex = self.selectedIndex } if !self.itemNodes.isEmpty { let itemSize = CGSize(width: floorToScreenPixels(size.width / CGFloat(self.itemNodes.count)), height: size.height) let selectionFrame = CGRect(origin: CGPoint(x: itemSize.width * CGFloat(selectedIndex), y: 0.0), size: itemSize).insetBy(dx: 4.0, dy: 4.0) transition.updateFrameAsPositionAndBounds(node: self.selectionNode, frame: selectionFrame) self.selectionNode.cornerRadius = selectionFrame.height / 2.0 for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] let highlightedItemNode = self.highlightedItemNodes[i] let _ = itemNode.measure(itemSize) transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: itemSize.width * CGFloat(i), y: (size.height - itemSize.height) / 2.0), size: itemSize)) transition.updateFrame(node: highlightedItemNode, frame: CGRect(origin: CGPoint(x: itemSize.width * CGFloat(i), y: (size.height - itemSize.height) / 2.0), size: itemSize)) let isSelected = selectedIndex == i if itemNode.isSelected != isSelected { if case .animated = transition { UIView.transition(with: itemNode.view, duration: 0.2, options: .transitionCrossDissolve, animations: { itemNode.isSelected = isSelected highlightedItemNode.isHidden = !isSelected }, completion: nil) } else { itemNode.isSelected = isSelected highlightedItemNode.isHidden = !isSelected } if isSelected { itemNode.accessibilityTraits.insert(.selected) } else { itemNode.accessibilityTraits.remove(.selected) } } } } } private func setupButtons() { for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] itemNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside) itemNode.highligthedChanged = { [weak self, weak itemNode] highlighted in if let strongSelf = self, let itemNode = itemNode { let transition = ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut) if strongSelf.selectedIndex == i { if let gestureRecognizer = strongSelf.gestureRecognizer, case .began = gestureRecognizer.state { } else { strongSelf.updateButtonsHighlights(highlightedIndex: highlighted ? i : nil, gestureSelectedIndex: strongSelf.gestureSelectedIndex) } } else if highlighted { transition.updateAlpha(node: itemNode, alpha: 0.4) } if !highlighted { transition.updateAlpha(node: itemNode, alpha: 1.0) } } } } } private func updateButtonsHighlights(highlightedIndex: Int?, gestureSelectedIndex: Int?) { let transition = ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut) if highlightedIndex == nil && gestureSelectedIndex == nil { transition.updateTransformScale(node: self.selectionNode, scale: 1.0) } else { transition.updateTransformScale(node: self.selectionNode, scale: 0.96) } for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] let highlightedItemNode = self.highlightedItemNodes[i] if i == highlightedIndex || i == gestureSelectedIndex { transition.updateTransformScale(node: itemNode, scale: 0.96) transition.updateTransformScale(node: highlightedItemNode, scale: 0.96) } else { transition.updateTransformScale(node: itemNode, scale: 1.0) transition.updateTransformScale(node: highlightedItemNode, scale: 1.0) } } } private func updateButtonsHighlights() { let transition = ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut) if let gestureSelectedIndex = self.gestureSelectedIndex { for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] let highlightedItemNode = self.highlightedItemNodes[i] transition.updateTransformScale(node: itemNode, scale: i == gestureSelectedIndex ? 0.96 : 1.0) transition.updateTransformScale(node: highlightedItemNode, scale: i == gestureSelectedIndex ? 0.96 : 1.0) } } else { for itemNode in self.itemNodes { transition.updateTransformScale(node: itemNode, scale: 1.0) } for itemNode in self.highlightedItemNodes { transition.updateTransformScale(node: itemNode, scale: 1.0) } } } @objc private func buttonPressed(_ button: HighlightTrackingButtonNode) { guard let index = self.itemNodes.firstIndex(of: button) else { return } self._selectedIndex = index self.selectedIndexChanged(index) if let size = self.validLayout { self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .slide)) } } public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { return self.selectionNode.frame.contains(gestureRecognizer.location(in: self.view)) } @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { let location = recognizer.location(in: self.view) switch recognizer.state { case .changed: if !self.selectionNode.frame.contains(location) { let point = CGPoint(x: max(0.0, min(self.bounds.width, location.x)), y: 1.0) for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] if itemNode.frame.contains(point) { if i != self.gestureSelectedIndex { self.gestureSelectedIndex = i self.updateButtonsHighlights(highlightedIndex: nil, gestureSelectedIndex: i) if let size = self.validLayout { self.updateLayout(size: size, transition: .animated(duration: 0.35, curve: .slide)) } } break } } } case .ended: if let gestureSelectedIndex = self.gestureSelectedIndex { if gestureSelectedIndex != self.selectedIndex { self._selectedIndex = gestureSelectedIndex self.selectedIndexChanged(gestureSelectedIndex) } self.gestureSelectedIndex = nil } self.updateButtonsHighlights(highlightedIndex: nil, gestureSelectedIndex: nil) default: break } } }