From a4c7c63ef30b25dfef10ce49fd51c23013679db3 Mon Sep 17 00:00:00 2001 From: "Pfeil, Scott Robert" Date: Thu, 28 Jan 2021 15:47:15 -0500 Subject: [PATCH] video molecule BGVideoImageMolecule --- MVMCoreUI.xcodeproj/project.pbxproj | 24 +++ MVMCoreUI/Atomic/Atoms/Views/Video.swift | 162 ++++++++++++++++++ .../Atomic/Atoms/Views/VideoDataManager.swift | 120 +++++++++++++ MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift | 58 +++++++ .../Atoms/Views/VideoPauseBehavior.swift | 110 ++++++++++++ .../BGVideoImageMolecule.swift | 98 +++++++++++ .../BGVideoImageMoleculeModel.swift | 38 ++++ .../ThreeLayerFillMiddleTemplate.swift | 8 + .../ScrollingViewController.swift | 6 + MVMCoreUI/Behaviors/PageBehavior.swift | 7 +- 10 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 MVMCoreUI/Atomic/Atoms/Views/Video.swift create mode 100644 MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift create mode 100644 MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift create mode 100644 MVMCoreUI/Atomic/Atoms/Views/VideoPauseBehavior.swift create mode 100644 MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMolecule.swift create mode 100644 MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMoleculeModel.swift diff --git a/MVMCoreUI.xcodeproj/project.pbxproj b/MVMCoreUI.xcodeproj/project.pbxproj index 2dc86525..a1a735a7 100644 --- a/MVMCoreUI.xcodeproj/project.pbxproj +++ b/MVMCoreUI.xcodeproj/project.pbxproj @@ -402,6 +402,12 @@ D28BA74D248589C800B75CB8 /* TabPageModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28BA74C248589C800B75CB8 /* TabPageModelProtocol.swift */; }; D296E14722A5984C0051EBE7 /* MVMCoreUIViewConstrainingProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = D296E14622A597490051EBE7 /* MVMCoreUIViewConstrainingProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; D29B771022C281F400D6ACE0 /* ModuleMolecule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29B770F22C281F400D6ACE0 /* ModuleMolecule.swift */; }; + D29C558A25C05C7D0082E7D6 /* BGVideoImageMoleculeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C558925C05C7D0082E7D6 /* BGVideoImageMoleculeModel.swift */; }; + D29C558D25C05C990082E7D6 /* BGVideoImageMolecule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C558C25C05C990082E7D6 /* BGVideoImageMolecule.swift */; }; + D29C559025C095210082E7D6 /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C558F25C095210082E7D6 /* Video.swift */; }; + D29C559325C0992D0082E7D6 /* VideoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C559225C0992D0082E7D6 /* VideoModel.swift */; }; + D29C559625C099630082E7D6 /* VideoDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C559525C099630082E7D6 /* VideoDataManager.swift */; }; + D29C559C25C20D6D0082E7D6 /* VideoPauseBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C559B25C20D6D0082E7D6 /* VideoPauseBehavior.swift */; }; D29C94D5242901C9003813BA /* MVMCoreUICommonViewsUtility+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C94D4242901C9003813BA /* MVMCoreUICommonViewsUtility+Extension.swift */; }; D29DF0D121E404D4003B2FB9 /* MVMCoreUI.h in Headers */ = {isa = PBXBuildFile; fileRef = D29DF0CF21E404D4003B2FB9 /* MVMCoreUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; D29DF0E621E4F3C7003B2FB9 /* MVMCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D29DF0E521E4F3C7003B2FB9 /* MVMCore.framework */; }; @@ -944,6 +950,12 @@ D28BA74C248589C800B75CB8 /* TabPageModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPageModelProtocol.swift; sourceTree = ""; }; D296E14622A597490051EBE7 /* MVMCoreUIViewConstrainingProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MVMCoreUIViewConstrainingProtocol.h; sourceTree = ""; }; D29B770F22C281F400D6ACE0 /* ModuleMolecule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleMolecule.swift; sourceTree = ""; }; + D29C558925C05C7D0082E7D6 /* BGVideoImageMoleculeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGVideoImageMoleculeModel.swift; sourceTree = ""; }; + D29C558C25C05C990082E7D6 /* BGVideoImageMolecule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGVideoImageMolecule.swift; sourceTree = ""; }; + D29C558F25C095210082E7D6 /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = ""; }; + D29C559225C0992D0082E7D6 /* VideoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoModel.swift; sourceTree = ""; }; + D29C559525C099630082E7D6 /* VideoDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDataManager.swift; sourceTree = ""; }; + D29C559B25C20D6D0082E7D6 /* VideoPauseBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPauseBehavior.swift; sourceTree = ""; }; D29C94D4242901C9003813BA /* MVMCoreUICommonViewsUtility+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MVMCoreUICommonViewsUtility+Extension.swift"; sourceTree = ""; }; D29DF0CC21E404D4003B2FB9 /* MVMCoreUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MVMCoreUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D29DF0CF21E404D4003B2FB9 /* MVMCoreUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MVMCoreUI.h; sourceTree = ""; }; @@ -1676,6 +1688,8 @@ D29B770F22C281F400D6ACE0 /* ModuleMolecule.swift */, D253BB9D2458751F002DE544 /* BGImageMoleculeModel.swift */, D253BB9B245874F8002DE544 /* BGImageMolecule.swift */, + D29C558925C05C7D0082E7D6 /* BGVideoImageMoleculeModel.swift */, + D29C558C25C05C990082E7D6 /* BGVideoImageMolecule.swift */, ); path = OtherContainers; sourceTree = ""; @@ -2020,6 +2034,10 @@ AA37CBD42519072F0027344C /* Stars.swift */, AA07EA902510A442009A2AE3 /* StarModel.swift */, AA07EA922510A451009A2AE3 /* Star.swift */, + D29C559525C099630082E7D6 /* VideoDataManager.swift */, + D29C559B25C20D6D0082E7D6 /* VideoPauseBehavior.swift */, + D29C559225C0992D0082E7D6 /* VideoModel.swift */, + D29C558F25C095210082E7D6 /* Video.swift */, ); path = Views; sourceTree = ""; @@ -2651,6 +2669,7 @@ 0A7EF85D23D8A95600B2AAD1 /* TextEntryFieldModel.swift in Sources */, BB54C5212434D92F0038326C /* ListRightVariableButtonAllTextAndLinksModel.swift in Sources */, D2092349244A51D40044AD09 /* RadioSwatchModel.swift in Sources */, + D29C559C25C20D6D0082E7D6 /* VideoPauseBehavior.swift in Sources */, 0A775F2824893937009EFB58 /* ThreeHeadlineBodyLinkModel.swift in Sources */, 8DD1E370243B3D0500D8F2DF /* ListThreeColumnInternationalData.swift in Sources */, D2EC7BDD2527B83700F540AF /* SectionHeaderFooterView.swift in Sources */, @@ -2742,6 +2761,7 @@ AA104AC924472DC7004D2810 /* HeadersH1ButtonModel.swift in Sources */, 0ABD1371237DB0450081388D /* ItemDropdownEntryField.swift in Sources */, D20C7009250BF99B0095B21C /* TopNotificationModel.swift in Sources */, + D29C558A25C05C7D0082E7D6 /* BGVideoImageMoleculeModel.swift in Sources */, 8D24041123E7FB9E009E23BE /* ListLeftVariableIconWithRightCaret.swift in Sources */, BB2FB3BD247E7EF200DF73CD /* Tags.swift in Sources */, AA104ADC244734EA004D2810 /* HeadersH1LandingPageHeaderModel.swift in Sources */, @@ -2781,6 +2801,7 @@ D27CD4102339057800C1DC07 /* EyebrowHeadlineBodyLink.swift in Sources */, AAB7EDF1246ADA2A00E54929 /* ListProgressBarThin.swift in Sources */, 8D070BB2241B56AD0099AC56 /* ListRightVariableTotalData.swift in Sources */, + D29C559325C0992D0082E7D6 /* VideoModel.swift in Sources */, D264FAA5243F66A500D98315 /* CollectionTemplateItemProtocol.swift in Sources */, D29DF11D21E684A9003B2FB9 /* MVMCoreUISplitViewController.m in Sources */, AA71AD3E24A32FCE00ACA76F /* HeadersH2LinkModel.swift in Sources */, @@ -2846,6 +2867,7 @@ D2ED2812254B0EB800A1C293 /* MVMCoreTopAlertObject.m in Sources */, C003506123AA94CD00B6AC29 /* Button.swift in Sources */, DBC4391B224421A0001AB423 /* CaretLink.swift in Sources */, + D29C559025C095210082E7D6 /* Video.swift in Sources */, D264FA90243BCE6800D98315 /* ThreeLayerCollectionViewController.swift in Sources */, AA104B1C24474A76004D2810 /* HeadersH2ButtonsModel.swift in Sources */, 0A6BF4722360C56C0028F841 /* BaseDropdownEntryField.swift in Sources */, @@ -2871,6 +2893,7 @@ BB6C6AC924225290005F7224 /* ListOneColumnTextWithWhitespaceDividerShortModel.swift in Sources */, C695A69423C9909000BFB94E /* DoughnutChartModel.swift in Sources */, D2D3957D252FDBCD00047B11 /* ModalSectionListTemplateModel.swift in Sources */, + D29C559625C099630082E7D6 /* VideoDataManager.swift in Sources */, 8D4687E2242E2DE400802879 /* ListFourColumnDataUsageListItemModel.swift in Sources */, D29E28DD23D7404C00ACEA85 /* ContainerHelper.swift in Sources */, 012A88C2238D7BCA00FE3DA1 /* CarouselItemModel.swift in Sources */, @@ -2888,6 +2911,7 @@ AA633B3324989ED500731E80 /* HeadersH2PricingTwoRows.swift in Sources */, 01509D8F2327EC6F00EF99AA /* MoleculeTableViewCell.swift in Sources */, 0A6682A22434DB4F00AD3CA1 /* ListLeftVariableRadioButtonBodyText.swift in Sources */, + D29C558D25C05C990082E7D6 /* BGVideoImageMolecule.swift in Sources */, EA5124FD243601600051A3A4 /* BGImageHeadlineBodyButton.swift in Sources */, 0105618D224BBE7700E1557D /* FormValidator.swift in Sources */, 01509D912327ECE600EF99AA /* CornerLabels.swift in Sources */, diff --git a/MVMCoreUI/Atomic/Atoms/Views/Video.swift b/MVMCoreUI/Atomic/Atoms/Views/Video.swift new file mode 100644 index 00000000..26904344 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/Video.swift @@ -0,0 +1,162 @@ +// +// Video.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/26/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// +import AVKit + +open class Video: View { + public let videoViewController = AVPlayerViewController() + + // TODO: reuse. + private let videoQueue = DispatchQueue(label: "serial.video.queue") + + /// Used to track the state and respond.. + private var stateKVOToken: NSKeyValueObservation? + + private weak var pauseBehavior: BlockPageVisibilityBehavior? + + private weak var scrollBehavior: BlockPageScrolledBehavior? + + private var visible = true + + open override func setupView() { + super.setupView() + videoViewController.view.translatesAutoresizingMaskIntoConstraints = false + addSubview(videoViewController.view) + NSLayoutConstraint.constraintPinSubview(toSuperview: videoViewController.view) + videoViewController.videoGravity = .resizeAspectFill + } + + open override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { + super.set(with: model, delegateObject, additionalData) + guard let model = model as? VideoModel else { return } + videoViewController.showsPlaybackControls = model.showControls + videoViewController.player = model.videoDataManager.player + addStateObserver() + switch (model.videoDataManager.videoState) { + case .none: + // Begin loading the video + model.videoDataManager.loadVideo() + case .loaded: + // Video loaded + if model.alwaysReset { + // Always start video at the beginning. + model.videoDataManager.player?.seek(to: .zero) + } + if model.autoPlay { + model.videoDataManager.player?.play() + } + default: + break + } + + // Handle pause behavior + addVisibleBehavoir(to: model, delegateObject: delegateObject) + addScrolledBehavoir(to: model, delegateObject: delegateObject) + } + + /// Listens and responds to video loading state changes. + private func addStateObserver() { + removeStateObserver() + + guard stateKVOToken == nil, + let model = model as? VideoModel else { return } + + // To know when the video player item is done loading. + stateKVOToken = + model.videoDataManager.observe(\.videoState) { [weak self] (item, change) in + guard let self = self, + let model = self.model as? VideoModel, + item == model.videoDataManager else { return } + + switch item.videoState { + case .loaded: + // Setting videoController's player must be in the main thread + MVMCoreDispatchUtility.performSyncBlock(onMainThread: { + // Play the video + self.videoViewController.player = item.player + if model.autoPlay { + item.player?.play() + } + UIAccessibility.post(notification: .screenChanged, argument: self) + }) + case .failed: + if let errorObject = item.loadFailedError { + MVMCoreLoggingHandler.shared()?.addError(toLog: errorObject) + } + default: + break + } + } + } + + private func removeStateObserver() { + stateKVOToken?.invalidate() + stateKVOToken = nil + } + + deinit { + removeStateObserver() + } + + /// Adds a behavoir to pause the video on page hidden behavoir and unpause if necessary on page shown. + open func addVisibleBehavoir(to model: VideoModel, delegateObject: MVMCoreUIDelegateObject?) { + let onShow = { [weak self] in + guard self?.visible == true, + let model = self?.model as? VideoModel, + model.autoPlay else { return } + model.videoDataManager.player?.play() + } + let onHide: () -> Void = { [weak self] in + guard self?.visible == true, + let model = self?.model as? VideoModel else { return } + model.videoDataManager.player?.pause() + } + + if pauseBehavior != nil { + pauseBehavior?.pageShownHandler = onShow + pauseBehavior?.pageHiddenHandler = onHide + } else { + guard let delegate = delegateObject?.moleculeDelegate, + let page = delegate as? PageProtocol, + var pageModel = page.pageModel as? PageBehaviorsTemplateProtocol else { return } + var behavoirs = pageModel.behaviors ?? [] + let pauseBehavior = BlockPageVisibilityBehavior(with: onShow, onPageHiddenHandler: onHide) + behavoirs.append(pauseBehavior) + pageModel.behaviors = behavoirs + self.pauseBehavior = pauseBehavior + } + } + + open func addScrolledBehavoir(to model: VideoModel, delegateObject: MVMCoreUIDelegateObject?) { + + let onScroll = { [weak self] (scrollView: UIScrollView) in + // If visible to not visible, pause video. + // If not visible to visible, unpause if needed, add visible behavior + // visible == +// if !visible { +// pause +// } else { +// let model = self?.model as? VideoModel, +// model.autoPlay else { return } +// model.videoDataManager.player?.play() +// } + } + + if scrollBehavior != nil { + scrollBehavior?.pageScrolledHandler = onScroll + } else { + guard let delegate = delegateObject?.moleculeDelegate, + let page = delegate as? PageProtocol, + var pageModel = page.pageModel as? PageBehaviorsTemplateProtocol else { return } + var behavoirs = pageModel.behaviors ?? [] + let scrollBehavior = BlockPageScrolledBehavior(with: onScroll) + behavoirs.append(scrollBehavior) + pageModel.behaviors = behavoirs + self.scrollBehavior = scrollBehavior + } + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift b/MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift new file mode 100644 index 00000000..ab6d74e4 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift @@ -0,0 +1,120 @@ +// +// VideoDataManager.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/26/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import Foundation + +@objcMembers open class VideoDataManager: NSObject { + + @objc public enum State: Int { + case none + case loading + case loaded + case failed + } + + public let videoURLString: String + public var player: AVPlayer? + + // Thread Safe video state handling. + private var _videoState = State.none + private let videoStatueQueue = DispatchQueue(label: "concurrent.video.state.queue", attributes: .concurrent) + + /// The state of the video. Use KVO to listen for state changes. + @objc public var videoState: State { + get { + var state = State.none + videoStatueQueue.sync { + state = _videoState + } + return state + } + set { + willChangeValue(for: \.videoState) + videoStatueQueue.async(flags: .barrier) { + self._videoState = newValue + } + didChangeValue(for: \.videoState) + } + } + + /// Set when the state is set to failed. Follows the same pattern as apple's AVPlayerItem + public var loadFailedError: MVMCoreErrorObject? + + private var kvoToken: NSKeyValueObservation? + + public init(with videoURLString: String) { + self.videoURLString = videoURLString + } + + public func loadVideo() { + guard videoState != .loading else { return } + removeVideoObserver() + player = nil + videoState = .loading + + //Asset loading needs time, calling async method. by tracking asset's propety "duration", if we get the value of duration, we can treat asset load successfully. + let tracksKey = "duration" + MVMCoreCache.shared()?.playerAsset(fromFileName: videoURLString, trackKeys: [tracksKey], onComplete: { [weak self] (asset, fileName, errorObject) in + guard let asset = asset else { + self?.loadFailedError = errorObject + self?.videoState = .failed + return + } + + var error: NSError? = nil + let tracksStatus = asset.statusOfValue(forKey: tracksKey, error: &error) + switch tracksStatus { + case .loaded: + //When Assets load successfully, we create playerItem and add playerItem into AVPlayer + self?.player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + self?.addObserverToPlayerItem() + case .failed: + //Asset load fail + //Since checking asset status here, no need to check player.currenItem.asset's media tracks when play button is clicked anymore. + if let error = error, + let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: (asset as? AVURLAsset)?.url.absoluteString ?? self?.videoURLString) { + self?.loadFailedError = errorObject + } + self?.videoState = .failed + default: + break + } + }) + } + + private func addObserverToPlayerItem() { + removeVideoObserver() + + // To know when the video player item is done loading. + guard kvoToken == nil else { return } + kvoToken = player?.currentItem?.observe(\.status) { [weak self] (item, change) in + guard item == self?.player?.currentItem else { return } + switch item.status { + case .readyToPlay: + self?.videoState = .loaded + case .failed: + if let error = item.error, + let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: (item.asset as? AVURLAsset)?.url.absoluteString ?? self?.videoURLString) { + self?.loadFailedError = errorObject + } + self?.videoState = .failed + default: + break + } + } + } + + private func removeVideoObserver() { + kvoToken?.invalidate() + kvoToken = nil + } + + deinit { + removeVideoObserver() + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift b/MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift new file mode 100644 index 00000000..d335f375 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift @@ -0,0 +1,58 @@ +// +// VideoModel.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/26/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import Foundation + +open class VideoModel: MoleculeModelProtocol { + public static var identifier = "video" + public var backgroundColor: Color? + public var video: String + public var showControls = false + public var autoPlay = true + public var alwaysReset = false + + /// Keeps a reference to the video data. + public var videoDataManager: VideoDataManager + + private enum CodingKeys: String, CodingKey { + case moleculeName + case video + case showControls + case autoPlay + case alwaysReset + } + + public init(_ video: String) { + self.video = video + videoDataManager = VideoDataManager(with: video) + } + + required public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + video = try typeContainer.decode(String.self, forKey:.video) + videoDataManager = VideoDataManager(with: video) + if let showControls = try typeContainer.decodeIfPresent(Bool.self, forKey: .showControls) { + self.showControls = showControls + } + if let autoPlay = try typeContainer.decodeIfPresent(Bool.self, forKey: .autoPlay) { + self.autoPlay = autoPlay + } + if let alwaysReset = try typeContainer.decodeIfPresent(Bool.self, forKey: .alwaysReset) { + self.alwaysReset = alwaysReset + } + } + + open func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(moleculeName, forKey: .moleculeName) + try container.encode(video, forKey: .video) + try container.encode(showControls, forKey: .showControls) + try container.encode(autoPlay, forKey: .autoPlay) + try container.encode(alwaysReset, forKey: .alwaysReset) + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Views/VideoPauseBehavior.swift b/MVMCoreUI/Atomic/Atoms/Views/VideoPauseBehavior.swift new file mode 100644 index 00000000..73293a8b --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/VideoPauseBehavior.swift @@ -0,0 +1,110 @@ +// +// VideoPauseBehavior.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/27/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import Foundation + +public class BlockPageVisibilityBehavior: PageVisibilityBehavior { + + public static var identifier = "blockPageVisibilityBehavior" + + public var pageShownHandler: () -> Void + public var pageHiddenHandler: () -> Void + + public init(with onPageShownHandler: @escaping () -> Void, onPageHiddenHandler: @escaping () -> Void) { + self.pageShownHandler = onPageShownHandler + self.pageHiddenHandler = onPageHiddenHandler + } + + private enum CodingKeys: String, CodingKey { + case behaviorName + } + + // This class is not meant to be decoded and encoded really. + public required init(from decoder: Decoder) throws { + pageShownHandler = {} + pageHiddenHandler = {} + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(behaviorName, forKey: .behaviorName) + } + + //MARK:- PageVisibilityBehavior + public func onPageShown() { + pageShownHandler() + } + + public func onPageHidden() { + pageHiddenHandler() + } +} + +public class BlockPageScrolledBehavior: PageScrolledBehavior { + + public static var identifier = "blockPageScrolledBehavior" + + public var pageScrolledHandler: (_ scrollView: UIScrollView) -> Void + + public init(with onPageScrolledHandler: @escaping (_ scrollView: UIScrollView) -> Void) { + self.pageScrolledHandler = onPageScrolledHandler + } + + private enum CodingKeys: String, CodingKey { + case behaviorName + } + + // This class is not meant to be decoded and encoded really. + public required init(from decoder: Decoder) throws { + pageScrolledHandler = { (scrollView) in } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(behaviorName, forKey: .behaviorName) + } + + public func pageScrolled(scrollView: UIScrollView) { + pageScrolledHandler(scrollView) + } +} + + +public class VideoPauseBehavior: PageVisibilityBehavior { + + public static var identifier = "videoPause" + + public weak var videoModel: VideoModel? + public init(with videoModel: VideoModel) { + self.videoModel = videoModel + } + + private enum CodingKeys: String, CodingKey { + case behaviorName + } + + //MARK:- PageVisibilityBehavior + + public func onPageShown() { + // TODO: Only do this if attached to a visible view... + if videoModel?.autoPlay == true { + videoModel?.videoDataManager.player?.play() + } + } + + public func onPageHidden() { + videoModel?.videoDataManager.player?.pause() + } + + public required init(from decoder: Decoder) throws {} + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(behaviorName, forKey: .behaviorName) + } +} diff --git a/MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMolecule.swift b/MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMolecule.swift new file mode 100644 index 00000000..f57298aa --- /dev/null +++ b/MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMolecule.swift @@ -0,0 +1,98 @@ +// +// BGVideoImageMolecule.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/26/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import UIKit + +open class BGVideoImageMolecule: BGImageMolecule { + + public let video = Video() + + /// Used to hide video after loaded. + private var stateKVOToken: NSKeyValueObservation? + private var endObserver: NSObjectProtocol? + + open override func setupView() { + super.setupView() + insertSubview(video, aboveSubview: image) + NSLayoutConstraint.constraintPinSubview(toSuperview: video) + } + + open override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { + super.set(with: model, delegateObject, additionalData) + guard let model = model as? BGVideoImageMoleculeModel else { return } + video.set(with: model.video, delegateObject, additionalData) + video.isHidden = shouldVideoBeHidden() + addStateObserver() + } + + open func shouldVideoBeHidden() -> Bool { + guard let model = model as? BGVideoImageMoleculeModel, + model.video.videoDataManager.videoState != .failed else { + return true + } + guard model.video.videoDataManager.videoState == .loaded, + let player = model.video.videoDataManager.player, + let item = player.currentItem, + item.currentTime() == item.duration else { + return false + } + return true + } + + /// Listens and responds to video loading state changes to add the end observer. + private func addStateObserver() { + removeStateObserver() + + guard stateKVOToken == nil, + let model = model as? BGVideoImageMoleculeModel else { return } + + // To know when the video player item is done loading. + stateKVOToken = + model.video.videoDataManager.observe(\.videoState) { [weak self] (item, change) in + guard let self = self, + let model = self.model as? BGVideoImageMoleculeModel, + item == model.video.videoDataManager else { return } + + switch item.videoState { + case .loaded: + self.addEndObserver() + case .failed: + MVMCoreDispatchUtility.performBlock(onMainThread: { + self.video.isHidden = true + }) + default: + break + } + } + } + + private func removeStateObserver() { + stateKVOToken?.invalidate() + stateKVOToken = nil + } + + private func addEndObserver() { + removeStateObserver() + guard let model = model as? BGVideoImageMoleculeModel, + let item = model.video.videoDataManager.player?.currentItem else { return } + endObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: item, queue: OperationQueue.main) { [weak self] (notification) in + self?.video.isHidden = true + } + } + + private func removeEndObserver() { + guard let endObserver = endObserver else { return } + NotificationCenter.default.removeObserver(endObserver) + self.endObserver = nil + } + + deinit { + removeStateObserver() + removeEndObserver() + } +} diff --git a/MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMoleculeModel.swift b/MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMoleculeModel.swift new file mode 100644 index 00000000..f2ee20af --- /dev/null +++ b/MVMCoreUI/Atomic/Molecules/OtherContainers/BGVideoImageMoleculeModel.swift @@ -0,0 +1,38 @@ +// +// BGVideoImageMoleculeModel.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/26/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import UIKit + +open class BGVideoImageMoleculeModel: BGImageMoleculeModel { + open override class var identifier: String { + return "bgVideoImageContainer" + } + + public var video: VideoModel + + private enum CodingKeys: String, CodingKey { + case video + } + + public init(_ video: VideoModel, image: ImageViewModel, molecule: MoleculeModelProtocol) { + self.video = video + super.init(image, molecule: molecule) + } + + required public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + video = try typeContainer.decode(VideoModel.self, forKey:.video) + try super.init(from: decoder) + } + + open override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(video, forKey: .video) + } +} diff --git a/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift b/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift index 50e55a45..81c3b6a8 100644 --- a/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift @@ -14,6 +14,14 @@ import Foundation return 0 } + open override func loadView() { + super.loadView() + // The height is used to keep the bottom view at the bottom. + if let contentView = contentView, let scrollView = scrollView { + contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.safeAreaLayoutGuide.heightAnchor).isActive = true + } + } + open override func handleNewData() { super.handleNewData() heightConstraint?.isActive = true diff --git a/MVMCoreUI/BaseControllers/ScrollingViewController.swift b/MVMCoreUI/BaseControllers/ScrollingViewController.swift index fcbcf878..9a9a42e3 100644 --- a/MVMCoreUI/BaseControllers/ScrollingViewController.swift +++ b/MVMCoreUI/BaseControllers/ScrollingViewController.swift @@ -73,6 +73,12 @@ open class ScrollingViewController: ViewController { scrollView.flashScrollIndicators() } + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + executeBehaviors { (behavior: PageScrolledBehavior) in + behavior.pageScrolled(scrollView: scrollView) + } + } + //-------------------------------------------------- // MARK: - Keyboard Handling //-------------------------------------------------- diff --git a/MVMCoreUI/Behaviors/PageBehavior.swift b/MVMCoreUI/Behaviors/PageBehavior.swift index d8fd99a3..ed17daff 100644 --- a/MVMCoreUI/Behaviors/PageBehavior.swift +++ b/MVMCoreUI/Behaviors/PageBehavior.swift @@ -37,8 +37,13 @@ public protocol PageVisibilityBehavior: PageBehaviorProtocol { } +public protocol PageScrolledBehavior: PageBehaviorProtocol { + + func pageScrolled(scrollView: UIScrollView) +} + public protocol PageBehaviorsTemplateProtocol { - var behaviors: [PageBehaviorProtocol]? { get } + var behaviors: [PageBehaviorProtocol]? { get set } }