diff --git a/MVMCoreUI.xcodeproj/project.pbxproj b/MVMCoreUI.xcodeproj/project.pbxproj index 9f7816ec..5c490b5a 100644 --- a/MVMCoreUI.xcodeproj/project.pbxproj +++ b/MVMCoreUI.xcodeproj/project.pbxproj @@ -347,6 +347,8 @@ D23EA800247EBD6C00D60C34 /* LabelBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23EA7FF247EBD6C00D60C34 /* LabelBarButtonItem.swift */; }; D23EA802247EBED400D60C34 /* ImageBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23EA801247EBED400D60C34 /* ImageBarButtonItem.swift */; }; D243859923A16B1800332775 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = D243859823A16B1800332775 /* Container.swift */; }; + D24918F625D5AD8E00CAB4B1 /* PageVisibilityClosureBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24918F525D5AD8E00CAB4B1 /* PageVisibilityClosureBehavior.swift */; }; + D24918FA25D5ADBB00CAB4B1 /* PageScrolledClosureBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24918F925D5ADBA00CAB4B1 /* PageScrolledClosureBehavior.swift */; }; D2509ED12472ED9B001BFB9D /* NavigationItemModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2509ED02472ED9B001BFB9D /* NavigationItemModelProtocol.swift */; }; D2509ED62472EE2F001BFB9D /* NavigationImageButtonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2509ED52472EE2F001BFB9D /* NavigationImageButtonModel.swift */; }; D253BB8A24574CC5002DE544 /* StackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D260106423D0CEA700764D80 /* StackModel.swift */; }; @@ -403,6 +405,11 @@ 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 */; }; 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 */; }; @@ -892,6 +899,8 @@ D23EA7FF247EBD6C00D60C34 /* LabelBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelBarButtonItem.swift; sourceTree = ""; }; D23EA801247EBED400D60C34 /* ImageBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBarButtonItem.swift; sourceTree = ""; }; D243859823A16B1800332775 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; + D24918F525D5AD8E00CAB4B1 /* PageVisibilityClosureBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVisibilityClosureBehavior.swift; sourceTree = ""; }; + D24918F925D5ADBA00CAB4B1 /* PageScrolledClosureBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageScrolledClosureBehavior.swift; sourceTree = ""; }; D2509ED02472ED9B001BFB9D /* NavigationItemModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationItemModelProtocol.swift; sourceTree = ""; }; D2509ED52472EE2F001BFB9D /* NavigationImageButtonModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationImageButtonModel.swift; sourceTree = ""; }; D253BB9B245874F8002DE544 /* BGImageMolecule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGImageMolecule.swift; sourceTree = ""; }; @@ -946,6 +955,11 @@ 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 = ""; }; 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 = ""; }; @@ -1228,6 +1242,8 @@ children = ( 27F973522466074500CAB5C5 /* PageBehavior.swift */, 27F97369246750BE00CAB5C5 /* ScreenBrightnessModifierBehavior.swift */, + D24918F525D5AD8E00CAB4B1 /* PageVisibilityClosureBehavior.swift */, + D24918F925D5ADBA00CAB4B1 /* PageScrolledClosureBehavior.swift */, ); path = Behaviors; sourceTree = ""; @@ -1679,6 +1695,8 @@ D29B770F22C281F400D6ACE0 /* ModuleMolecule.swift */, D253BB9D2458751F002DE544 /* BGImageMoleculeModel.swift */, D253BB9B245874F8002DE544 /* BGImageMolecule.swift */, + D29C558925C05C7D0082E7D6 /* BGVideoImageMoleculeModel.swift */, + D29C558C25C05C990082E7D6 /* BGVideoImageMolecule.swift */, ); path = OtherContainers; sourceTree = ""; @@ -2023,6 +2041,9 @@ AA37CBD42519072F0027344C /* Stars.swift */, AA07EA902510A442009A2AE3 /* StarModel.swift */, AA07EA922510A451009A2AE3 /* Star.swift */, + D29C559525C099630082E7D6 /* VideoDataManager.swift */, + D29C559225C0992D0082E7D6 /* VideoModel.swift */, + D29C558F25C095210082E7D6 /* Video.swift */, ); path = Views; sourceTree = ""; @@ -2688,6 +2709,7 @@ 94C2D9A523872C350006CF46 /* LabelAttributeFontModel.swift in Sources */, 011D958724042492000E3791 /* FormFieldProtocol.swift in Sources */, 011D95AF2407266E000E3791 /* RadioButtonModel.swift in Sources */, + D24918F625D5AD8E00CAB4B1 /* PageVisibilityClosureBehavior.swift in Sources */, D20492A624329CE200A5EED6 /* LoadImageView.swift in Sources */, 017BEB7F23676E870024EF95 /* MoleculeObjectMapping.swift in Sources */, D274CA332236A78900B01B62 /* FooterView.swift in Sources */, @@ -2745,6 +2767,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 */, @@ -2772,6 +2795,7 @@ 012A88DB238ED45900FE3DA1 /* CarouselModel.swift in Sources */, D2092355244FA0FD0044AD09 /* ThreeLayerTemplateModelProtocol.swift in Sources */, 0AE14F64238315D2005417F8 /* TextField.swift in Sources */, + D24918FA25D5ADBB00CAB4B1 /* PageScrolledClosureBehavior.swift in Sources */, 0A51F3E22475CB73002E08B6 /* LoadingSpinnerModel.swift in Sources */, D2169303251E53D9002A6324 /* SectionListTemplateModel.swift in Sources */, 0AB764D124460F6300E7FE72 /* UIDatePicker+Extension.swift in Sources */, @@ -2784,6 +2808,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 */, @@ -2850,6 +2875,7 @@ 0AA4D2E125CAEC72008DB32D /* AccessibilityModelProtocol.swift 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 */, @@ -2875,6 +2901,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 */, @@ -2892,6 +2919,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/LoadImageView.swift b/MVMCoreUI/Atomic/Atoms/Views/LoadImageView.swift index aafddb00..8d8ef49e 100644 --- a/MVMCoreUI/Atomic/Atoms/Views/LoadImageView.swift +++ b/MVMCoreUI/Atomic/Atoms/Views/LoadImageView.swift @@ -21,7 +21,8 @@ public var addSizeConstraintsForAspectRatio = true public var shouldNotifyDelegateOnUpdate = true - + public var shouldNotifyDelegateOnDefaultSizeChange = false + // Allows for a view to hardcode which height to use if there is none in the json. var imageWidth: CGFloat? var imageHeight: CGFloat? @@ -228,7 +229,7 @@ let widthWillChange = !MVMCoreGetterUtility.cgfequal(widthConstraint?.constant ?? 0, width ?? 0) let heightWillChange = !MVMCoreGetterUtility.cgfequal(heightConstraint?.constant ?? 0, height ?? 0) - let sizeWillChange = (width == nil || height == nil) && !(size?.equalTo(imageView.image?.size ?? CGSize.zero) ?? false) + let sizeWillChange = shouldNotifyDelegateOnDefaultSizeChange && (width == nil || height == nil) && !(size?.equalTo(imageView.image?.size ?? CGSize.zero) ?? false) let heightChangeFromSpinner = (heightConstraint?.isActive ?? false) ? false : ((height ?? size?.height) ?? 0) < loadingSpinnerHeightConstraint?.constant ?? CGFloat.leastNormalMagnitude return widthWillChange || heightWillChange || sizeWillChange || heightChangeFromSpinner } diff --git a/MVMCoreUI/Atomic/Atoms/Views/Video.swift b/MVMCoreUI/Atomic/Atoms/Views/Video.swift new file mode 100644 index 00000000..baee04f6 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/Video.swift @@ -0,0 +1,108 @@ +// +// 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() + private weak var containingView: UIView? + + /// Used to track the state and respond.. + private var stateKVOToken: NSKeyValueObservation? + + open override func setupView() { + super.setupView() + videoViewController.view.translatesAutoresizingMaskIntoConstraints = false + addSubview(videoViewController.view) + NSLayoutConstraint.constraintPinSubview(toSuperview: videoViewController.view) + videoViewController.videoGravity = .resizeAspectFill + } + + /// Checks if the video is visible in the molecule delegate + open func isVisibleInDelegate() -> Bool { + guard let containingView = containingView else { return true } + return isVisible(in: containingView) + } + + /// Checks if the video is visible in the passed in view + open func isVisible(in view: UIView) -> Bool { + return MVMCoreUIUtility.isView(self, visibleIn: view) + } + + open override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { + /// Detach the view from it's previous model before setting. + (self.model as? VideoModel)?.view = nil + containingView = (delegateObject?.moleculeDelegate as? UIViewController)?.view + super.set(with: model, delegateObject, additionalData) + + guard let model = model as? VideoModel else { return } + if let controller = delegateObject?.moleculeDelegate as? UIViewController { + controller.addChild(videoViewController) + videoViewController.didMove(toParent: controller) + } + videoViewController.showsPlaybackControls = model.showControls + videoViewController.player = model.videoDataManager.player + addStateObserver() + model.addVisibilityHalting(for: self, delegateObject: delegateObject) + + switch (model.videoDataManager.videoState) { + case .none: + // Begin loading the video + model.videoDataManager.loadVideo() + case .loaded: + guard isVisibleInDelegate() else { return } + // Video loaded, unhalt it if necessary. + model.halted = false + default: + break + } + } + + /// 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.halted && model.autoPlay && self.isVisibleInDelegate() { + 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() + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift b/MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift new file mode 100644 index 00000000..a4ba9235 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/VideoDataManager.swift @@ -0,0 +1,141 @@ +// +// VideoDataManager.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 1/26/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import Foundation +import AVFoundation + +@objcMembers open class VideoDataManager: NSObject { + + /// The state of the video. + @objc public enum VideoState: 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 = VideoState.none + private let videoStatueQueue = DispatchQueue(label: "com.vzw.mvmcoreui.videoDataManager.state", attributes: .concurrent) + + /// The state of the video. Use KVO to listen for state changes. + @objc public var videoState: VideoState { + get { + var state = VideoState.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? + + private var memoryWarningListener: Any? + + public init(with videoURLString: String) { + self.videoURLString = videoURLString + super.init() + self.addMemoryWarningListener() + } + + 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 + } + + private func addMemoryWarningListener() { + memoryWarningListener = NotificationCenter.default.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: OperationQueue.main) { [weak self] (notification) in + self?.removeVideoObserver() + self?.player = nil + self?.videoState = .none + } + } + + private func removeMemoryWarningListener() { + guard let observer = memoryWarningListener else { return } + NotificationCenter.default.removeObserver(observer) + memoryWarningListener = nil + } + + deinit { + removeVideoObserver() + removeMemoryWarningListener() + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift b/MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift new file mode 100644 index 00000000..1d079599 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/VideoModel.swift @@ -0,0 +1,167 @@ +// +// 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 + weak public var view: Video? + + /// When the video is halted because it is no longer visible + public var halted: Bool = false { + didSet { + guard halted != oldValue, + videoDataManager.videoState == .loaded else { return } + if halted { + videoDataManager.player?.pause() + } else { + if alwaysReset { + // Always start video at the beginning. + videoDataManager.player?.seek(to: .zero) + } + if autoPlay { + videoDataManager.player?.play() + } + } + } + } + + /// Keeps a reference to the video data. + public var videoDataManager: VideoDataManager + + private weak var visibleBehavior: PageVisibilityClosureBehavior? + private weak var scrollBehavior: PageScrolledClosureBehavior? + private var activeListener: Any? + private var resignActiveListener: Any? + + 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) + 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 + } + videoDataManager = VideoDataManager(with: video) + } + + 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) + } + + open func addVisibilityHalting(for view: Video, delegateObject: MVMCoreUIDelegateObject?) { + self.view = view + halted = false + addVisibleBehavior(for: view, delegateObject: delegateObject) + addScrollBehavior(for: view, delegateObject: delegateObject) + addActiveListener(for: view, delegateObject: delegateObject) + } + + /// Adds a behavior to pause the video on page hidden behavior and unpause if necessary on page shown. + open func addVisibleBehavior(for view: Video, delegateObject: MVMCoreUIDelegateObject?) { + + let onShow = { [weak self] in + guard let self = self, + let view = self.view, + view.isVisibleInDelegate() else { return } + self.halted = false + } + let onHide: () -> Void = { [weak self] in + self?.halted = true + } + + guard visibleBehavior == nil else { + visibleBehavior?.pageShownHandler = onShow + visibleBehavior?.pageHiddenHandler = onHide + return + } + + guard var delegate = delegateObject?.behaviorTemplateDelegate else { return } + let pauseBehavior = PageVisibilityClosureBehavior(with: onShow, onPageHiddenHandler: onHide) + delegate.add(behavior: pauseBehavior) + self.visibleBehavior = pauseBehavior + } + + /// Adds a behavior to pause the video if scrolled off of the page and unpause if necessary if scrolled on. + open func addScrollBehavior(for view: Video, 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 + guard let self = self, + let view = self.view else { return } + self.halted = !view.isVisible(in: scrollView) + } + + guard scrollBehavior == nil else { + scrollBehavior?.pageScrolledHandler = onScroll + return + } + + guard var delegate = delegateObject?.behaviorTemplateDelegate else { return } + let scrollBehavior = PageScrolledClosureBehavior(with: onScroll) + delegate.add(behavior: scrollBehavior) + self.scrollBehavior = scrollBehavior + } + + open func addActiveListener(for view: Video, delegateObject: MVMCoreUIDelegateObject?) { + removeActiveListener() + + resignActiveListener = NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: OperationQueue.main) { [weak self] (notification) in + self?.halted = true + } + activeListener = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: OperationQueue.main) { [weak self] (notification) in + guard let self = self, + let view = self.view, + view.isVisibleInDelegate() else { return } + self.halted = false + } + } + + private func removeActiveListener() { + if let observer = activeListener { + NotificationCenter.default.removeObserver(observer) + activeListener = nil + } + if let observer = resignActiveListener { + NotificationCenter.default.removeObserver(observer) + resignActiveListener = nil + } + } + + deinit { + removeActiveListener() + } +} diff --git a/MVMCoreUI/Atomic/MoleculeObjectMapping.swift b/MVMCoreUI/Atomic/MoleculeObjectMapping.swift index 559b611b..e4d3a59a 100644 --- a/MVMCoreUI/Atomic/MoleculeObjectMapping.swift +++ b/MVMCoreUI/Atomic/MoleculeObjectMapping.swift @@ -106,6 +106,7 @@ import Foundation MoleculeObjectMapping.shared()?.register(viewClass: RadioButtonLabel.self, viewModelClass: RadioButtonLabelModel.self) MoleculeObjectMapping.shared()?.register(viewClass: WebView.self, viewModelClass: WebViewModel.self) MoleculeObjectMapping.shared()?.register(viewClass: LoadingSpinner.self, viewModelClass: LoadingSpinnerModel.self) + MoleculeObjectMapping.shared()?.register(viewClass: Video.self, viewModelClass: VideoModel.self) // MARK:- Horizontal Combination Molecules MoleculeObjectMapping.shared()?.register(viewClass: StringAndMoleculeView.self, viewModelClass: StringAndMoleculeModel.self) @@ -151,6 +152,7 @@ import Foundation MoleculeObjectMapping.shared()?.register(viewClass: Scroller.self, viewModelClass: ScrollerModel.self) MoleculeObjectMapping.shared()?.register(viewClass: ModuleMolecule.self, viewModelClass: ModuleMoleculeModel.self) MoleculeObjectMapping.shared()?.register(viewClass: BGImageMolecule.self, viewModelClass: BGImageMoleculeModel.self) + MoleculeObjectMapping.shared()?.register(viewClass: BGVideoImageMolecule.self, viewModelClass: BGVideoImageMoleculeModel.self) MoleculeObjectMapping.shared()?.register(viewClass: MoleculeSectionHeader.self, viewModelClass: MoleculeSectionHeaderModel.self) MoleculeObjectMapping.shared()?.register(viewClass: MoleculeSectionFooter.self, viewModelClass: MoleculeSectionFooterModel.self) diff --git a/MVMCoreUI/Atomic/Molecules/OtherContainers/BGImageMoleculeModel.swift b/MVMCoreUI/Atomic/Molecules/OtherContainers/BGImageMoleculeModel.swift index 2dbfced5..56b77e3e 100644 --- a/MVMCoreUI/Atomic/Molecules/OtherContainers/BGImageMoleculeModel.swift +++ b/MVMCoreUI/Atomic/Molecules/OtherContainers/BGImageMoleculeModel.swift @@ -27,6 +27,12 @@ open class BGImageMoleculeModel: MoleculeContainerModel { if bottomPadding == nil { bottomPadding = PaddingDefaultVerticalSpacing3 } + if image.contentMode == nil { + image.contentMode = .scaleAspectFill + } + if image.imageFormat == nil { + image.imageFormat = "jpg" + } } private enum CodingKeys: String, CodingKey { 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..19c9d001 100644 --- a/MVMCoreUI/BaseControllers/ScrollingViewController.swift +++ b/MVMCoreUI/BaseControllers/ScrollingViewController.swift @@ -73,6 +73,12 @@ open class ScrollingViewController: ViewController { scrollView.flashScrollIndicators() } + open 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 9e52f778..aaf915a1 100644 --- a/MVMCoreUI/Behaviors/PageBehavior.swift +++ b/MVMCoreUI/Behaviors/PageBehavior.swift @@ -35,7 +35,29 @@ public protocol PageVisibilityBehavior: PageBehaviorProtocol { func onPageHidden() } +public protocol PageScrolledBehavior: PageBehaviorProtocol { + + func pageScrolled(scrollView: UIScrollView) +} + public protocol PageBehaviorsTemplateProtocol { - var behaviors: [PageBehaviorProtocol]? { get } + var behaviors: [PageBehaviorProtocol]? { get set } + +} + +public extension PageBehaviorsTemplateProtocol { + mutating func add(behavior: PageBehaviorProtocol) { + var newBehaviors = behaviors ?? [] + newBehaviors.append(behavior) + self.behaviors = newBehaviors + } +} + +public extension MVMCoreUIDelegateObject { + weak var behaviorTemplateDelegate: (PageBehaviorsTemplateProtocol & NSObjectProtocol)? { + get { + return (moleculeDelegate as? PageProtocol)?.pageModel as? (PageBehaviorsTemplateProtocol & NSObjectProtocol) + } + } } diff --git a/MVMCoreUI/Behaviors/PageScrolledClosureBehavior.swift b/MVMCoreUI/Behaviors/PageScrolledClosureBehavior.swift new file mode 100644 index 00000000..c43d4ecc --- /dev/null +++ b/MVMCoreUI/Behaviors/PageScrolledClosureBehavior.swift @@ -0,0 +1,33 @@ +// +// PageScrolledClosureBehavior.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 2/11/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import Foundation + +public class PageScrolledClosureBehavior: PageScrolledBehavior { + + public static var identifier = "pageScrolledClosureBehavior" + + public var pageScrolledHandler: (_ scrollView: UIScrollView) -> Void + + public init(with onPageScrolledHandler: @escaping (_ scrollView: UIScrollView) -> Void) { + self.pageScrolledHandler = onPageScrolledHandler + } + + // This class is not meant to be decoded and encoded really. + public required init(from decoder: Decoder) throws { + throw ModelRegistry.Error.decoderOther(message: "PageScrolledClosureBehavior does not decode.") + } + + public func encode(to encoder: Encoder) throws { + throw ModelRegistry.Error.decoderOther(message: "PageScrolledClosureBehavior does not encode.") + } + + public func pageScrolled(scrollView: UIScrollView) { + pageScrolledHandler(scrollView) + } +} diff --git a/MVMCoreUI/Behaviors/PageVisibilityClosureBehavior.swift b/MVMCoreUI/Behaviors/PageVisibilityClosureBehavior.swift new file mode 100644 index 00000000..f5ecd82c --- /dev/null +++ b/MVMCoreUI/Behaviors/PageVisibilityClosureBehavior.swift @@ -0,0 +1,40 @@ +// +// PageVisibilityClosureBehavior.swift +// MVMCoreUI +// +// Created by Scott Pfeil on 2/11/21. +// Copyright © 2021 Verizon Wireless. All rights reserved. +// + +import Foundation + +public class PageVisibilityClosureBehavior: PageVisibilityBehavior { + + public static var identifier = "pageVisibilityClosureBehavior" + + public var pageShownHandler: () -> Void + public var pageHiddenHandler: () -> Void + + public init(with onPageShownHandler: @escaping () -> Void, onPageHiddenHandler: @escaping () -> Void) { + self.pageShownHandler = onPageShownHandler + self.pageHiddenHandler = onPageHiddenHandler + } + + // This class is not meant to be decoded and encoded really. + public required init(from decoder: Decoder) throws { + throw ModelRegistry.Error.decoderOther(message: "PageVisibilityClosureBehavior does not decode.") + } + + public func encode(to encoder: Encoder) throws { + throw ModelRegistry.Error.decoderOther(message: "PageVisibilityClosureBehavior does not encode.") + } + + //MARK:- PageVisibilityBehavior + public func onPageShown() { + pageShownHandler() + } + + public func onPageHidden() { + pageHiddenHandler() + } +} diff --git a/MVMCoreUI/OtherHandlers/CoreUIObject.swift b/MVMCoreUI/OtherHandlers/CoreUIObject.swift index 3a4d2cbc..90532f98 100644 --- a/MVMCoreUI/OtherHandlers/CoreUIObject.swift +++ b/MVMCoreUI/OtherHandlers/CoreUIObject.swift @@ -13,6 +13,7 @@ import UIKit public var globalTopAlertDelegate: MVMCoreGlobalTopAlertDelegateProtocol? open override func defaultInitialSetup() { + loadHandler = MVMCoreLoadHandler() cache = MVMCoreCache() sessionHandler = MVMCoreSessionTimeHandler() actionHandler = MVMCoreActionHandler() @@ -21,5 +22,6 @@ import UIKit loggingDelegate = MVMCoreUILoggingHandler() moleculeMap = MoleculeObjectMapping() MoleculeObjectMapping.registerObjects() + clientParameterRegistry = ClientParameterRegistry() } } diff --git a/MVMCoreUI/Utility/MVMCoreUIUtility.h b/MVMCoreUI/Utility/MVMCoreUIUtility.h index 4bf6e8d5..74b19d0e 100644 --- a/MVMCoreUI/Utility/MVMCoreUIUtility.h +++ b/MVMCoreUI/Utility/MVMCoreUIUtility.h @@ -37,6 +37,14 @@ NS_ASSUME_NONNULL_BEGIN /// Checks if the view or any descendents of the view is currently focused for voice over. + (BOOL)viewContainsAccessiblityFocus:(nonnull UIView *)view; ++ (BOOL)isView:(nonnull UIView *)view visibleIn:(nonnull UIView *)rootView; + ++ (BOOL)isViewVisibleInParent:(nonnull UIView *)view; + ++ (BOOL)doesView:(nonnull UIView *)overlappingView cover:(nonnull UIView *)view; + ++ (BOOL)isViewTransparent:(nonnull UIView *)view; + #pragma mark - Setters + (void)setMarginsForView:(nullable UIView *)view leading:(CGFloat)leading top:(CGFloat)top trailing:(CGFloat)trailing bottom:(CGFloat)bottom; diff --git a/MVMCoreUI/Utility/MVMCoreUIUtility.m b/MVMCoreUI/Utility/MVMCoreUIUtility.m index fed0bb30..fad2378c 100644 --- a/MVMCoreUI/Utility/MVMCoreUIUtility.m +++ b/MVMCoreUI/Utility/MVMCoreUIUtility.m @@ -96,6 +96,51 @@ return containsFocus; } ++ (BOOL)isView:(nonnull UIView *)view visibleIn:(nonnull UIView *)rootView { + return [view isDescendantOfView:rootView] && [self isViewVisibleInParent:view]; +} + +// Climb up the tree. Break early. ++ (BOOL)isViewVisibleInParent:(nonnull UIView *)view { + UIView *superview = view.superview; + UIView *ancestor = view; + // Begin climbing its ancestor views, checking if it remains visible in *each* of their bounds and if the children at views closer to the front block the visibility of this view. + while (superview) { + if (superview.clipsToBounds && !CGRectIntersectsRect(superview.bounds, [view convertRect:view.bounds toView:superview])) { + return false; + } else { + // Check the superview's children up to the common ancestor. (Reworking the ancestor would put us in an infinite loop and we want to ignore the children with a lower z-order.) + for (UIView *subview in [superview.subviews reverseObjectEnumerator]) { + if (subview == ancestor) { + break; + } else if ([self doesView:subview cover:view]) { + return false; + } + } + } + ancestor = superview; + superview = superview.superview; + } + return true; +} + +// Climb down the tree. ++ (BOOL)doesView:(nonnull UIView *)overlappingView cover:(nonnull UIView *)view { + if (![self isViewTransparent:overlappingView] && CGRectContainsRect(overlappingView.bounds, [view convertRect:view.bounds toView:overlappingView])) { + return true; + } + for (UIView *subview in overlappingView.subviews) { + if ([self doesView:subview cover:view]) { + return true; + } + } + return false; +} + ++ (BOOL)isViewTransparent:(nonnull UIView *)view { + return view.alpha < 1 || view.backgroundColor == nil || CGColorGetAlpha(view.backgroundColor.CGColor) < 1; +} + #pragma mark - Setters + (void)setMarginsForView:(nullable UIView *)view leading:(CGFloat)leading top:(CGFloat)top trailing:(CGFloat)trailing bottom:(CGFloat)bottom {