diff --git a/Sources/DefinedElements/Frameworks/App/iOS/DApp-iOS.Root.swift b/Sources/DefinedElements/Frameworks/App/iOS/DApp-iOS.Root.swift new file mode 100644 index 0000000..bf871c7 --- /dev/null +++ b/Sources/DefinedElements/Frameworks/App/iOS/DApp-iOS.Root.swift @@ -0,0 +1,23 @@ +#if os(iOS) + +import Foundation +import SwiftUI + +internal struct DefinedAppRoot : Scene where StartPage: DefinedPage { + var start: StartPage + + init(from: StartPage) { + self.start = from + } + + var body: some Scene { + WindowGroup { + EmptyView() + .onAppear(perform: { + UIApplication.startApplication(from: self.start) + }) + } + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/App/iOS/StatusBar/DApp-iOS.StatusBar.swift b/Sources/DefinedElements/Frameworks/App/iOS/StatusBar/DApp-iOS.StatusBar.swift new file mode 100644 index 0000000..bd0eeaf --- /dev/null +++ b/Sources/DefinedElements/Frameworks/App/iOS/StatusBar/DApp-iOS.StatusBar.swift @@ -0,0 +1,49 @@ +#if os(iOS) + +import Foundation +import SwiftUI + +/// [DE Internal] A root view controller alternative to empower the ability of changing status bar style. +internal class DefinedStatusBarController: UIHostingController { + /// The current status bar style holder. + var statusBarStyle: UIStatusBarStyle = .darkContent + + override var preferredStatusBarStyle: UIStatusBarStyle { + return statusBarStyle + } + + /// Change the style holder inside the controller by given style. + /// + /// - Parameter style: The target style. + func changeStatusBarStyle(_ style: UIStatusBarStyle) { + self.statusBarStyle = style + self.setNeedsStatusBarAppearanceUpdate() + } +} + +internal extension UIApplication { + /// [DE Internal] Set the start page of the app and empower the ability of changing status bar style. + /// + /// - Parameter from: The root page (start page for the root view stack) of an app. + class func startApplication(from page: StartPage) where StartPage: DefinedPage { + UIApplication.shared.windows.first?.rootViewController?.beginAppearanceTransition(false, animated: false) + UIApplication.shared.windows.first?.rootViewController = DefinedStatusBarController(rootView: DefinedViewStack(from: page)) + } + + /// [DE Internal] Set the status bar style over the entire app. + /// + /// Handling the status bar style modification should be in `DefinedViewStack` system instead of here. + /// + /// - Parameter style: The target style. + class func setStatusBarStyle(_ style: UIStatusBarStyle) { + if let viewController = UIApplication.getKeyWindow()?.rootViewController as? DefinedStatusBarController { + viewController.changeStatusBarStyle(style) + } + } + + private class func getKeyWindow() -> UIWindow? { + return UIApplication.shared.windows.first{ $0.isKeyWindow } + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/Page/iOS/DPage-iOS.Protocol.swift b/Sources/DefinedElements/Frameworks/Page/iOS/DPage-iOS.Protocol.swift new file mode 100644 index 0000000..a8daa2f --- /dev/null +++ b/Sources/DefinedElements/Frameworks/Page/iOS/DPage-iOS.Protocol.swift @@ -0,0 +1,362 @@ +#if os(iOS) + +import SwiftUI + +/// [DE] A protocol for building a page that fits into page system. +/// +/// To distinguish between a Page (an entire area showing some contents) and a View (a content component), we define the `DefinedPage` so that the page can have its special APIs specifying for a page and work much better with the view stack system. +/// +/// We empower the page the ability of linking to another page, jumping to another page, back to the previous page, etc. Those are not even possible if we mix the Page and View up because a View should not have such things directly. +/// +/// - Note: Currently, we have done the optimization on algorithm, but I am not able to do the performance optimization on raw executing layer and rendering layer yet (still learning). I am planning to finish all performance optimizations in our 2.0 release. +/// +/// - Important: `DefinedViewStack` really relys on this protocol! +public protocol DefinedPage : View, ModifiableStruct { + + // MARK: - Protocol - Main Part + + /// The main controller using to control your current page. + /// + /// This is optional, but highly recommanded because you will lose the control without it. + /// Only NOT implement it when this page is used for an only-one-page stack. + /// + /// You only need to do a simple work: + /// + /// ``` swift + /// var controller: DefinedPageController = .init() + /// ``` + /// + /// - Important: If you do not implement this on your page, + /// you may NOT be able to control the router! + /// Even the short-hand functions like ``link(to:)`` and ``jump(to:)`` will all be disabled without controller! + var controller: DefinedPageController { get } + + /// The page id that will be unique universally. + /// + /// Being used on determining the page identity in `DefinedViewStack` system and `DefinedViewManager` system. + var id: UUID { get } + + /// The status bar style of this page. + /// + /// The default value is `.darkContent` if developer does not define this variable locally. + /// You will not be able to do any change without defining it locally. + /// + /// You can define this variable as the status bar style on starting up this page. + /// + /// ``` swift + /// // make status bar content white on starting up the page. + /// var statusBarStyle: UIStatusBarStyle = .lightContent + /// ``` + /// + /// You can make this variable `@State` to modify the status bar style dynamically. + /// + /// ``` swift + /// // make status bar content white on starting up the page + /// // and can be dynamically modify during the runtime. + /// @State var statusBarStyle: UIStatusBarStyle = .lightContent + /// + /// func run() { + /// // make the status bar content black + /// self.statusBarStyle = .darkContent + /// } + /// ``` + var statusBarStyle: UIStatusBarStyle { get } + + /// [DE ShouldNotUse] The body of the page view. + /// + /// You should implement the body on `main` instead of here. + /// Do NOT implement this variable on your `DefinedPage`! + /// + /// - Important: You should NOT implement anything on `body`! It will break the functionalities of everything (it is due to the original SwiftUI View system, we are planning to build our own RawView system in 2.0 release). + var body: Self.Body { get } + + /// The type of view representing the content of this page. + /// + /// This will be associated automatically when you implement the required property ``main``. + associatedtype Content: View + + /// The main content of this page. Need to be implemented. + @ViewBuilder var main: Content { get } + + /// Execute before the page starts loading its content. + /// + /// - Note: You do NOT need to implement it if you do not execute anything at this phase. + func beforePageLoading() -> Void + + /// Execute after the page finishes loading all of its content. + /// + /// - Note: You do NOT need to implement it if you do not execute anything at this phase. + func onPageLoaded() -> Void + + /// Execute right before the page has been destroyed and collected. + /// + /// - Note: You do NOT need to implement it if you do not execute anything at this phase. + func onPageEnded() -> Void +} + +public extension DefinedPage { + /// Inactive `DefinedPageController`. + /// + /// If you do NOT define `controller` property locally, the PageController will be disabled! + /// Even though it is still accessible (we have no way to hide it out), it will not work at all. + var controller: DefinedPageController { + DefinedPageController() + } + + /// The page id that will be unique universally. + /// + /// Being used on determining the page identity in `DefinedViewStack` system and `DefinedViewManager` system. + /// + /// - Note: The actual page id is stored inside the PageController since we cannot have stored property in the extension. This is a getter so if developer does not specify another stored `id` in the page, the page can still have a stored ID to be identified. + var id: UUID { + controller.id + } +} + +// MARK: - LifeCycle + +public extension DefinedPage { + /// Execute before the page starts loading its content. Do nothing. + /// + /// - Note: You do NOT need to implement it if you do not execute anything at this phase. + func beforePageLoading() -> Void { + // do nothing. + return + } + + /// Execute after the page finishes loading all of its content. Do nothing. + /// + /// - Note: You do NOT need to implement it if you do not execute anything at this phase. + func onPageLoaded() -> Void { + // do nothing. + return + } + + /// Execute right before the page has been destroyed and collected. Do nothing. + /// + /// - Note: You do NOT need to implement it if you do not execute anything at this phase. + func onPageEnded() -> Void { + // do nothing. + return + } +} + +// MARK: - StatusBarStyle + +public extension DefinedPage { + /// The status bar style of this page. + /// + /// The default value is `.darkContent` if developer does not define this variable locally. + /// You will not be able to do any change without defining it locally. + /// + /// For more information, check the `statusBarStyle` property in ``DefinedPage`` protocol. + var statusBarStyle: UIStatusBarStyle { .darkContent } +} + +// MARK: - ShortHand - link() + +public extension DefinedPage { + /// [DE] Link to another page. + /// + /// Link means going to the next level (sub-page/child-page). + /// The current page will be the ancestor of the target page. + /// + /// When you need to link to another page, just call it in your `DefinedPage`: + /// + /// ``` swift + /// link(to: TargetPage()) + /// ``` + /// + /// You can do something like "link when clicking": + /// + /// ``` swift + /// Button("fake button") { + /// // link to TargetPage when clicking the "fake button". + /// link(to: TargetPage()) + /// } + /// ``` + /// + /// - Requires: The target page should conform to ``DefinedPage`` protocol. + /// + /// - Parameters: + /// - target: The target page. + func link(to target: Page) where Page: DefinedPage { + DefinedViewManager.find(self).link(to: target) + } + + /// [DE] Link to another page after a few seconds. + /// + /// Link means going to the next level (sub-page/child-page). + /// The current page will be the ancestor of the target page. + /// + /// When you need to link to another page, just call it in your `DefinedPage`: + /// + /// ``` swift + /// // link to TargetPage after 1 sec + /// link(to: TargetPage(), delay: 1.0) + /// ``` + /// + /// You can do something like "link when clicking": + /// + /// ``` swift + /// Button("fake button") { + /// // link to TargetPage in 1 sec after clicking + /// link(to: TargetPage(), delay: 1.0) + /// } + /// ``` + /// + /// - Requires: The target page should conform to ``DefinedPage`` protocol. + /// + /// - Parameters: + /// - target: The target page. + /// - delay: The time you want to delay (in second). + func link(to target: Page, delay: Double) where Page: DefinedPage { + if delay >= 0.0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + link(to: target) + } + } + } +} + +// MARK: - ShortHand - back() + +extension DefinedPage { + /// [DE] Back to the previous page. + /// + /// Back means pop up the current page and step back to the previous level. + /// The current page will be destroyed and collected. + /// + /// When you need to go back to the previous page, just call it in your `DefinedPage`: + /// + /// ``` swift + /// // back to the previous page when executing + /// back() + /// ``` + /// + /// You can do something like "back button": + /// + /// ``` swift + /// Button("back button") { + /// // back to the previous page when clicking + /// back() + /// } + /// ``` + public func back() { + DefinedViewManager.find(self).back() + } +} + +// MARK: - ShortHand - jump() + +public extension DefinedPage { + /// [DE] Jump to another page. + /// + /// Jump means cleaning the entire view stack and put target page at the new first level. + /// The current page will be destroyed and collected. + /// And you are not able to use `back()` because the target page becomes the first level page and there is no way to go back from the first level page. + /// + /// When you need to jump to another page, just call it in your `DefinedPage`: + /// + /// ``` swift + /// // replace all views in stack with the target page + /// jump(to: TargetPage()) + /// ``` + /// + /// You can do something like "jump when clicking": + /// + /// ``` swift + /// Button("fake button") { + /// // jump to the target page when clicking + /// jump(to: TargetPage()) + /// } + /// ``` + /// + /// - Requires: The target page should conform to ``DefinedPage`` protocol. + /// + /// - Parameters: + /// - target: The target page. + func jump(to target: Page) where Page: DefinedPage { + DefinedViewManager.find(self).jump(to: target) + } + + /// [DE] Jump to another page after a few seconds. + /// + /// Jump means cleaning the entire view stack and put target page at the new first level. + /// The current page will be destroyed and collected. + /// And you are not able to use `back()` because the target page becomes the first level page and there is no way to go back from the first level page. + /// + /// When you need to jump to another page, just call it in your `DefinedPage`: + /// + /// ``` swift + /// // jump to the target page after 1 sec + /// jump(to: TargetPage(), delay: 1.0) + /// ``` + /// + /// You can do something like "jump when clicking": + /// + /// ``` swift + /// Button("fake button") { + /// // jump to the target page in 1 sec after clicking + /// jump(to: TargetPage(), delay: 1.0) + /// } + /// ``` + /// + /// - Requires: The target page should conform to ``DefinedPage`` protocol. + /// + /// - Parameters: + /// - target: The target page. + /// - delay: The time you want to delay (in second). + func jump(to target: Page, delay: Double) where Page: DefinedPage { + if delay >= 0.0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + jump(to: target) + } + } + } +} + +// MARK: - ShortHand - Timer + +extension DefinedPage { + /// [DE] A timer. Execute as a function. + /// + /// - Parameters: + /// - delay: The time you want to delay (in second). + /// - execute: The code you want to execute after a few seconds. + public func timer(delay: Double, execute: @escaping @convention(block) () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + execute() + } + } +} + +// MARK: - Core - Process + +extension DefinedPage { + /// [DE ShouldNotUse] The body of the page view. + /// + /// You should implement the body on `main` instead of here. + /// + /// - Important: You should NOT implement anything on `body`! It will break the functionalities of everything (it is due to the original SwiftUI View system, we are planning to build our own RawView system in 2.0 release). + public var body: some View { + beforePageLoading() + return process() + } + + /// [DE Private] Process the page body. + @ViewBuilder private func process() -> some View { + ZStack { + self.main + } + .onAppear(perform: onPageLoaded) + .onDisappear(perform: onPageEnded) + .onChange(of: self.statusBarStyle, perform: { status in + DefinedViewManager.find(self).setStatusBarStyle(self.statusBarStyle) + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(DEColor.bg.light.edgesIgnoringSafeArea(.all)) // TODO: customize page background API + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/Page/iOS/Support/DPage-iOS.Controller.swift b/Sources/DefinedElements/Frameworks/Page/iOS/Support/DPage-iOS.Controller.swift new file mode 100644 index 0000000..577cc6e --- /dev/null +++ b/Sources/DefinedElements/Frameworks/Page/iOS/Support/DPage-iOS.Controller.swift @@ -0,0 +1,63 @@ +import Foundation + +/// [DE] A controller managing the page and all of its functionalities. +/// +/// You should only use this on your `DefinedPage`, like: +/// +/// ``` swift +/// var controller: DefinedPageController = .init() +/// ``` +/// +/// - TODO: `swap` and `jump` features. +public class DefinedPageController { + /// The page id associated with the controlled page. + /// + /// Should be internal only. + internal let id: UUID = UUID() + + /// [DE] Simply create a normal PageController. + public init() { + // do nothing. + } + + /// [DE] Link to another page. + /// + /// Link means going to the next level (sub-page/child-page). + /// The current page will be the ancestor of the target page. + /// + /// - Note: Please be aware that when you are executing `link(to:)`, you are adding a page onto the stack that this page is at. It is NOT adding the new page directly above the this page (if you mean to insert, then it is NOT). So when you are executing this outside the current page, you need to make sure where the target page will be. + /// + /// - Requires: The target page should conform to ``DefinedPage`` protocol. + /// + /// - Parameters: + /// - target: The target page. + public func link(to target: Page) where Page: DefinedPage { + DefinedViewManager.find(self.id).link(to: target) + } + + /// [DE] Back to the previous page. + /// + /// Back means pop up the current page and step back to the previous level. + /// The current page will be destroyed and collected. + /// + /// - Note: Please be aware that when you are executing `back()`, you are poping the top page of the stack that this page is at. It is NOT poping current page wherever it is. So when you are executing this outside the current page, you need to make sure which page will be popped. + public func back() { + DefinedViewManager.find(self.id).back() + } + + /// [DE] Jump to another page. + /// + /// Jump means cleaning the entire view stack and put target page at the new first level. + /// The current page will be destroyed and collected. + /// And you are not able to use `back()` because the target page becomes the first level page and there is no way to go back from the first level page. + /// + /// - Note: Please be aware that when you are executing `jump(to:)`, you are putting target page to the stack that this page is at and replace all old pages. The risk may be that the page you are at will be destroyed and collected since the original stack (whatever embeded or not) is cleaned. And if you are doing this to another stack that differs from the stack you are at, you also have to know that the page may not display per following the hierarchy, but it actually executed. So when you are executing this outside the current page, you need to make sure where the target page will be. + /// + /// - Requires: The target page should conform to ``DefinedPage`` protocol. + /// + /// - Parameters: + /// - target: The target page. + public func jump(to target: Page) where Page: DefinedPage { + DefinedViewManager.find(self.id).jump(to: target) + } +} diff --git a/Sources/DefinedElements/Frameworks/View/Manager/DVManager.Main.swift b/Sources/DefinedElements/Frameworks/View/Manager/DVManager.Main.swift new file mode 100644 index 0000000..ee09711 --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Manager/DVManager.Main.swift @@ -0,0 +1,123 @@ +#if os(iOS) + +import Foundation +import SwiftUI + +/// [DE Internal] A core manager controlling the view stack. +/// +/// Should be attached to a `DefinedViewStack` for proper using. +/// +/// - TODO: Reconsider the use case of making this public. +internal class DefinedViewManager { + /// [DE Internal] Global instance of `DefinedViewManager`. + static var instance = DefinedViewManager() + + /// An array containing all root elements. + /// + /// This array is designed for holding root elements in order to get a sense of stack pool. + /// Each stack corresponds to a root element, so when we need to control or clean up, we can search from here. + /// It is different from normal manager element because that corresponds to a page, which is the sub-item of our root element. + var hierarchy: [DefinedViewManagerRootElement] = [] + + /// A hash map containing all active pages. + var pageMap: [UUID: DefinedViewManagerElement] = [:] + + /// [DE Internal] Register a `DefinedViewStack` into global manager. + /// + /// - Parameters: + /// - stackManager: The manager property of `DeefinedViewStack`. + /// - parent: The parent stack where this stack stands on. + /// - under: The page that this stack stands on. This is optional, but highly recommanded. If you registered a stack without associating the parent page `under` (not the parent stack), this stack and its pages will not be collected automatically when the parent page is destroyed. + static func registerStack( + manager stackManager: DefinedViewStackManager, + parent: DefinedViewManagerRootElement, + under: DefinedViewManagerElement? = nil + ) { + let rootElement = DefinedViewManagerRootElement(docker: DefinedViewStackDocker(manager: stackManager), + parent: parent) + + if (stackManager.elements.first == nil) { + // ERROR: no initial page! + DefinedWarning.send(from: "DVManager", "the manager (stack manager) does not have any page inside!") + return + } + + let pageElement = DefinedViewManager.registerPage(id: stackManager.elements.first!.id, parent: rootElement) + rootElement.hierarchy.append(pageElement) + under?.register(rootElement) + DefinedViewManager.instance.hierarchy.append(rootElement) + } + + /// [DE Internal] Unregister a `DefinedViewStack` into global manager. + /// + /// - Parameter root: The root element of the stack you want to unregister. You can get the root element by `DefinedViewManager.find(page).parent` where `page` could be any page on this stack. + static func unregisterStack(root: DefinedViewManagerRootElement) { + DefinedViewManager.instance.hierarchy.removeAll(where: { curr in + if curr == root { + for page in curr.hierarchy { + DefinedViewManager.unregisterPage(id: page.id) + } + return true + } + return false + }) + } + + /// [DE Internal] Find the page manager by given page id. + /// + /// - Parameter id: The page id. + /// - Returns: The page manager corresponding to the given page id. + /// + /// - Note: It will return a dummy and generate a warning if we cannot find it in order to not break the program. + static func find(_ id: UUID) -> DefinedViewManagerElement { + guard let element = DefinedViewManager.instance.pageMap[id] else { + DefinedWarning.send(from: "DVManager", "cannot find the page manager by given page id.") + return .dummy + } + return element + } + + /// [DE Internal] Find the page manager by given page instance. + /// + /// - Parameter page: The `DefinedPage` instance. + /// - Returns: The page manager corresponding to the given page. + /// + /// - Note: It will return a dummy and generate a warning if we cannot find it in order to not break the program. + static func find(_ page: Page) -> DefinedViewManagerElement where Page: DefinedPage { + guard let element = DefinedViewManager.instance.pageMap[page.id] else { + DefinedWarning.send(from: "DVManager", "cannot find the page manager by given DefinedPage.") + return .dummy + } + return element + } + + /// [DE Internal] Register a `DefinedPage` into global manager. + /// + /// This may be called automatically. + /// + /// - Parameters: + /// - id: The id of the `DefinedPage` being registered. + /// - parent: The root element of the stack where this page is in. + /// - Returns: The newly created page manager corresponding to the page that is just registered. + static func registerPage(id: UUID, parent: DefinedViewManagerRootElement) -> DefinedViewManagerElement { + let pageElement = DefinedViewManagerElement(id: id, parent: parent) + DefinedViewManager.instance.pageMap[id] = pageElement + return pageElement + } + + /// [DE Internal] Unregister a `DefinedPage` from global manager. + /// + /// This may be called automatically for safely collecting the unavailable pages. + /// + /// - Parameters: + /// - id: The id of the `DefinedPage` being unregistered. + static func unregisterPage(id: UUID) { + guard let page = DefinedViewManager.instance.pageMap.removeValue(forKey: id) else { + DefinedWarning.send(from: "DVManager", "the page being unregistered does not exist!") + return + } + page.unregister() + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/View/Manager/Support/DVManager.Element.swift b/Sources/DefinedElements/Frameworks/View/Manager/Support/DVManager.Element.swift new file mode 100644 index 0000000..75e81bc --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Manager/Support/DVManager.Element.swift @@ -0,0 +1,222 @@ +#if os(iOS) + +import SwiftUI + +/// [DE Internal] A ViewManager element representing a page instance. +internal class DefinedViewManagerElement : Equatable { + /// The id of the corresponding page. + var id: UUID + + /// The parent stack's ViewManager element. + var parent: DefinedViewManagerRootElement + + /// The stacks that are hold in this page. + var stacks: [DefinedViewManagerRootElement] = [] + + /// [DE Internal] Create a `DVManagerElement`. + /// + /// - Parameters: + /// - id: The page id. + /// - parent: The parent stack's ViewManager element. + internal init(id: UUID, parent: DefinedViewManagerRootElement) { + self.id = id + self.parent = parent + } + + /// [DE Internal] Link. + func link(to target: Page) where Page: DefinedPage { + parent.link(to: target) + } + + /// [DE Internal] Jump. + func jump(to target: Page) where Page: DefinedPage { + parent.jump(to: target) + } + + /// [DE Internal] Swap. + func swap(with target: Page) where Page: DefinedPage { + parent.swap(with: target) + } + + /// [DE Internal] Back. + func back() { + parent.back() + } + + /// [DE Internal] Set status bar style of this page. + func setStatusBarStyle(_ style: UIStatusBarStyle) { + parent.docker?.setStatusBarStyle(pageId: self.id, style: style) + } + + /// [DE Internal] Register a new stack under this page. + /// + /// - Parameter root: The ViewManager element of the new stack (not a page!). + func register(_ root: DefinedViewManagerRootElement) { + self.stacks.append(root) + } + + /// [DE Internal] Unregister all stacks under current page. + /// + /// It should be called when the current page is destroyed or closed. + /// For garbage clean up purpose. + func unregister() { + for r in stacks { + // run unregister stack procedure for every stack under the current page. + DefinedViewManager.unregisterStack(root: r) + } + } + + static func == (lhs: DefinedViewManagerElement, rhs: DefinedViewManagerElement) -> Bool { + return lhs.id == rhs.id + } +} + +extension DefinedViewManagerElement { + /// The dummy page's ViewManager element. + /// + /// Being used when we cannot find the corresponding page's ViewManager element. Being designed to avoid a bunch of optional wrappers and to throw no error in order to keep all other functions work fine. + /// + /// When we cannot find the ViewManager element for a page by whatever reason, we will return this dummy element. + /// It can work as the same as all other elements but it does nothing. So the program will not crash. + /// The only thing going to happen is that you will find a warning in terminal and no expected navigation will be triggered. + /// + /// The reason why we do not make it an optional maneuver is that you actually should NOT try to catch it on runtime. You should absolutely avoid it before making it a production! So keep an eye on the terminal if you find something unexpected! + static let dummy: DefinedViewManagerElement = DefinedViewManagerDummyElement() +} + +/// [DE Internal] A dummy element class for `DVManagerElement`. +internal class DefinedViewManagerDummyElement : DefinedViewManagerElement { + init() { + super.init(id: UUID(), parent: .dummy) + } + + override func link(to target: Page) where Page: DefinedPage { + // do nothing. + } + + override func jump(to target: Page) where Page: DefinedPage { + // do nothing. + } + + override func swap(with target: Page) where Page: DefinedPage { + // do nothing. + } + + override func back() { + // do nothing. + } + + override func setStatusBarStyle(_ style: UIStatusBarStyle) { + // do nothing. + } + + override func register(_ root: DefinedViewManagerRootElement) { + // do nothing. + } + + override func unregister() { + // do nothing. + } +} + +/// [DE Internal] A ViewManager element representing a stack instance (not a page!). +internal class DefinedViewManagerRootElement : DefinedPotentialWarning, Equatable { + var name: String = "DVManagerRootElement" + + /// The id of this element. + /// + /// It is neither an ID from outside nor syncing with other components. + var id: UUID = UUID() + + /// All pages' ViewManager elements from current view stack. + var hierarchy: [DefinedViewManagerElement] = [] + + /// A docker linking to the actual view stack instance behind this ViewManager element. + var docker: DefinedViewStackDocker? = nil + + /// The parent stack's ViewManager element (should not have one only if it is the root stack). + var parent: DefinedViewManagerRootElement? = nil + + /// [DE Private] Create a root or dummy `DVManagerRootElement` that might not have a docker and a parent. + /// + /// - Parameters: + /// - docker: The docker linking to the actual view stack instance behind this ViewManager element. + /// - parent: The parent stack's ViewManager element. + fileprivate init(docker: DefinedViewStackDocker? = nil, parent: DefinedViewManagerRootElement? = nil) { + self.docker = docker + self.parent = parent + } + + /// [DE Internal] Create a `DVManagerRootElement`. + /// + /// Create the root stack's root element by using another initializer with optional docker and parent. Thus, this initializer requires the both. + /// + /// - Parameters: + /// - docker: The docker linking to the actual view stack instance behind this ViewManager element. + /// - parent: The parent stack's ViewManager element. + internal init(docker: DefinedViewStackDocker, parent: DefinedViewManagerRootElement) { + self.docker = docker + self.parent = parent + } + + /// [DE Internal] Link. + internal func link(to target: Page) where Page: DefinedPage { + if (self.docker == nil) { + // ERROR + return + } + self.docker!.link(to: target) + + self.hierarchy.append(DefinedViewManager.registerPage(id: target.id, parent: self)) + } + + /// [DE Internal] Jump. + internal func jump(to target: Page) where Page: DefinedPage { + if (self.docker == nil) { + // ERROR + return + } + self.docker!.jump(to: target) + } + + /// [DE Internal] Swap. + internal func swap(with target: Page) where Page: DefinedPage { + if (self.docker == nil) { + // ERROR + return + } + self.docker!.swap(with: target) + } + + /// [DE Internal] Back. + internal func back() { + if (self.docker == nil) { + // ERROR + warning("docker is null") + return + } + if (hierarchy.count > 1) { + self.docker!.back() + // unregister it from the ViewManager after pop it out to avoid double execute. + let last = self.hierarchy.removeLast() + DefinedViewManager.unregisterPage(id: last.id) + } else { + // TODO: back the parent page if back-parent enabled. + // TODO: implement back-parent feature. + } + } + + static func == (lhs: DefinedViewManagerRootElement, rhs: DefinedViewManagerRootElement) -> Bool { + return lhs.id == rhs.id + } +} + +extension DefinedViewManagerRootElement { + /// Representing the root stack of entire page system. + internal static let base = DefinedViewManagerRootElement() + + /// To be used as the parent of the dummy page's ViewManager element. + fileprivate static let dummy = DefinedViewManagerRootElement() +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Container.swift b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Container.swift new file mode 100644 index 0000000..634706d --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Container.swift @@ -0,0 +1,46 @@ +#if os(iOS) + +import Foundation + +/// [DE Internal] A view stack container instance holding page elements. +internal struct DefinedViewStackContainer { + /// The stack instance holding page elements. + var stack = [DefinedViewStackElement]() + + /// The count of elements in this container. + var count: Int { + return stack.count + } + + /// [DE Internal] Get the stack directly. + /// + /// - Note: For `elements` property updating purpose only! + internal func getStack() -> [DefinedViewStackElement] { + return stack + } + + /// [DE Internal] Push a page element onto the stack. + mutating func push(_ target: DefinedViewStackElement) { + guard stack.firstIndex(of: target) == nil else { + // if there exists a page element having same id with target page, it means that something went wrong. + // we should never push the same page twice (we can have different instances of the same page, but no exactly same page instance). + return + } + stack.append(target) + } + + /// [DE Internal] Pop a page element onto the stack. + /// + /// We do NOT return the element since it becomes totally useless once it has been popped. + /// There makes no sense to get it back outside. + mutating func pop() { + _ = stack.popLast() + } + + /// [DE Internal] Clean the entire container. + mutating func removeAll() { + stack.removeAll() + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Docker.swift b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Docker.swift new file mode 100644 index 0000000..33ffa18 --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Docker.swift @@ -0,0 +1,77 @@ +#if os(iOS) + +import SwiftUI + +/// [DE Internal] A docker connecting between `DVStackManager` and `DVManagerElement`. +internal struct DefinedViewStackDocker : DefinedPotentialWarning { + internal var name: String = "DVStackDocker" + + /// The corresponding `DVStackManager`. + /// + /// - Note: Was planned to use it directly on building the page so it kept optional. Don't worry! For now we forced an required `DVStackManager` in `init(manager:)`. + internal var manager: DefinedViewStackManager? + + /// [DE Internal] Create a docker with given `DVStackManager`. + /// + /// - Parameter manager: The corresponding `DVStackManager`. + internal init(manager: DefinedViewStackManager) { + self.manager = manager + } + + /// [DE Internal] Link to the target page. + /// + /// - Parameter target: The target page. + internal func link(to target: Page) where Page: DefinedPage { + if (self.manager != nil) { + manager!.push(target) + } else { + warning("the manager is null!") + } + } + + /// [DE Internal] Jump to the target page. + /// + /// - Parameter target: The target page. + internal func jump(to target: Page) where Page: DefinedPage { + if (self.manager != nil) { + manager!.jump(target) + } else { + warning("the manager is null!") + } + } + + /// [DE Internal] Swap with the target page. + /// + /// - Parameter target: The target page. + internal func swap(with target: Page) where Page: DefinedPage { + if (self.manager != nil) { + // + } else { + warning("the manager is null!") + } + } + + /// [DE Internal] Go back to previous page. + internal func back() { + if (self.manager != nil) { + manager!.pop() + } else { + warning("the manager is null!") + } + } + + /// [DE Internal] Set status bar style by given page id and target style. + /// + /// - Parameters: + /// - pageId: The id of the page that going to change its status bar style. + /// - style: The target style. + internal func setStatusBarStyle(pageId: UUID, style: UIStatusBarStyle) { + if (self.manager != nil) { + manager!.setStatusBarStyle(pageId: pageId, style: style) + } else { + warning("the manager is null!") + } + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Element.swift b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Element.swift new file mode 100644 index 0000000..a7dd707 --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Element.swift @@ -0,0 +1,54 @@ +#if os(iOS) + +import Foundation +import SwiftUI + +/// [DE Internal] A view stack element holder using for `DefinedViewStack`. +/// +/// - Note: The reason why we do NOT use generic type for `DefinedPage` is we will not be able to define an array of this. +/// - Note: We should use `class` instead of `struct` to avoid unwanted re-init of the values. +internal class DefinedViewStackElement : Identifiable, Equatable { + /// [Deprecated] + /// + /// This may be deprecated. It is used to make sure that the newer page is always above the elder page. + /// We need to make sure that the z-index things are not affected by multiple stacks support before removing this. + /// Everytime we create a new stack element, it self-increases. + private static var constantLevel: Int = 0 + + /// The id of the page held by this stack element. + let id: UUID + + /// The level of this stack element. + /// + /// It should obtained automatically from `constantLevel` property. + let level: Int + + /// The page held by this stack element. + let content: AnyView + + /// The status bar setup of this page (held by this stack element). + /// + /// This property will be modified synchronously when the `statusBarStyle` property of the page has been changed. + var statusBarStyle: UIStatusBarStyle + + /// [DE Internal] Create a ViewStack element by given page. + /// + /// - Parameter page: The page for this stack element. + init(_ page: Page) where Page: DefinedPage { + self.id = page.id + self.level = DefinedViewStackElement.constantLevel + self.content = AnyView(page) + self.statusBarStyle = page.statusBarStyle + + DefinedViewStackElement.constantLevel += 1 + } + + /// Compare to ViewStack Element by comparing their page id. + /// + /// When page ids are the same, they should be the same element. Otherwise, there is a bug. + static func == (lhs: DefinedViewStackElement, rhs: DefinedViewStackElement) -> Bool { + return lhs.id == rhs.id + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Main.swift b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Main.swift new file mode 100644 index 0000000..c171a8a --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Main.swift @@ -0,0 +1,172 @@ +#if os(iOS) + +import Foundation +import SwiftUI + +/// [DE] A view stack holding `DefinedPage`. +/// +/// - BUG: Swipe over-edge bug. +/// - BUG: Swipe back gesture detection is not perfect. +public struct DefinedViewStack : DefinedView { + + /// A manager controlling the view stack. + /// + /// Should be observed and generate from `DVStackManager` global instance. + /// To be used on managing pages and interactions. + @ObservedObject var manager: DefinedViewStackManager + + /// The name of this `DefinedViewStack`. + /// + /// Should be unique per page (if there are multiple embeded view stacks in one page). + private var name: String + + /// The parent page id of this `DefinedViewStack`. + private var parentId: UUID + + /// The namespace for this view stack. + @Namespace private var space + + /// [DE Internal] Create a view stack by given start page. + /// + /// This should be used internally on starting the app (the root stack). + /// + /// - Parameters: + /// - from: The start page of this stack. + internal init(from start: StartPage) where StartPage: DefinedPage { + self.name = "DVStackCoreRootInternal" + self.parentId = DefinedViewStackManager.rootId + + let shouldRegister = DefinedViewStackManager.shouldRegister(name: name, pageId: parentId) + self.manager = DefinedViewStackManager.get( + name: "DVStackCoreRootInternal", + pageId: parentId, + shouldUseStatusBar: true + ) + + if shouldRegister { + self.manager.viewStack.push(DefinedViewStackElement(start)) + DefinedViewManager.registerStack( + manager: self.manager, + parent: .base + ) + } + } + + /// [DE] Create a view stack by given start page and the parent page. + /// + /// This should be used on developing a stack in another page. + /// + /// - Parameters: + /// - name: The name of the view stack. + /// - from: The start page of this stack. + /// - at: The parent page (the page holding this stack, NOT the root page of this stack). + /// - statusBar: True if this stack should change the status bar style while directing. (optional, default false) + public init( + name: String, + from start: StartPage, + at parent: ParentPage, + statusBar shouldUseStatusBar: Bool = false + ) where StartPage: DefinedPage, ParentPage: DefinedPage { + self.name = name + self.parentId = parent.id + + let shouldRegister = DefinedViewStackManager.shouldRegister(name: name, pageId: parentId) + self.manager = DefinedViewStackManager.get(name: name, pageId: parentId, shouldUseStatusBar: shouldUseStatusBar) + + if shouldRegister { + self.manager.viewStack.push(DefinedViewStackElement(start)) + DefinedViewManager.registerStack( + manager: self.manager, + parent: DefinedViewManager.find(parent).parent, + under: DefinedViewManager.find(parent) + ) + } + } + + // MARK: - Body + + /// The core body view of this view stack. + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .center) { // MARK: View Part + if self.manager.elements.count >= 1 { + ForEach(0...self.manager.elements.count-1, id: \.self) { i in + self.manager.elements[i].content + .offset(x: i == self.manager.elements.count - 1 || i == self.manager.elements.count - 2 ? self.manager.offsets[i] : 0) + .matchedGeometryEffect(id: self.manager.elements[i].id, in: self.space) + .overlay( + DefinedContent(.overlay) { + if i == self.manager.elements.count - 2 { + Color.black.opacity(0.06) + .edgesIgnoringSafeArea(.all) + .transition(.opacity) + .allowsHitTesting(false) + } + } + ) + .transition(i == 0 ? .opacity : .move(edge: .trailing)) + .zIndex(Double(i)) + .visibility(show: self.manager.onAnimated && i >= self.manager.elements.count - 2) + } + } else { + EmptyView() + } + } + .overlay( + // MARK: Drag Part + // TODO: make drag better + DefinedContent(.overlay, alignment: .leading) { + if self.manager.elements.count > 1 { + Color.clear + .frame(width: 22) + .frame(maxHeight: .infinity, alignment: .leading) + .background(Color.blue.opacity(0)) + .contentShape(Rectangle()) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.01) + .onEnded({ gesture in + // do nothing + }), + including: .all + ) + .simultaneousGesture( + DragGesture(coordinateSpace: .local) + .onChanged({ gesture in + if self.manager.elements.count > 1 { + withAnimation(.easeInOut(duration: 0.12)) { + self.manager.offsets[self.manager.elements.count - 1] = gesture.location.x / 1.2 + self.manager.offsets[self.manager.elements.count - 2] = -proxy.size.width / 4 + gesture.location.x / 4.8 + } + } + }) + .onEnded({ gesture in + if gesture.predictedEndTranslation.width > 150 { + // call the main manager to pop instead of popping from stack manager directly. + // unregister the page and pop from current stack. + DefinedViewManager.find(self.manager.elements.last!.id).back() + } else { + if self.manager.elements.count > 1 { + withAnimation(.easeInOut(duration: 0.15)) { + self.manager.offsets[self.manager.elements.count - 1] = 0 + self.manager.offsets[self.manager.elements.count - 2] = -proxy.size.width / 4 + } + } + } + }), + including: .all + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + ) + .disabled(self.manager.onRootAnimated) + .onAppear { + self.manager.width = proxy.size.width + self.manager.height = proxy.size.height + self.manager.safeAreaInsets = proxy.safeAreaInsets + } + } + } +} + +#endif diff --git a/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Manager.swift b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Manager.swift new file mode 100644 index 0000000..028a917 --- /dev/null +++ b/Sources/DefinedElements/Frameworks/View/Stack/DVStack.Manager.swift @@ -0,0 +1,234 @@ +#if os(iOS) + +import SwiftUI + +/// [DE Internal] A core manager controlling the view stack. +/// +/// Should be attached to a `DefinedViewStack` for proper using. +/// One `StackManager` per `DefinedViewStack`. +/// +/// - TODO: Support `onAnimated` and optimize the display procedure algorithm. +internal class DefinedViewStackManager : ObservableObject { + /// The name of the view stack. + /// + /// It should be the same with the `name` property in corresponding `DefinedViewStack`. + /// For identification purpose (per page). + var name: String + + /// Should this view stack modify the status bar on changing the page? + /// + /// Sometimes, our view stack is not showing fullscreen. In this case, we should not modify the status bar style per the page is changed or the status bar style has been set manually because the page has not covered the status bar area. + var shouldUseStatusBar: Bool + + /// [Deprecated] A flag showing if the view stack is animating. + /// + /// It should only be `true` when we have a sub-page animates. + /// Should not be `true` when we have a root animation. + @Published var onAnimated: Bool = true + + /// [Deprecated] A flag showing if the root of a view stack is animating. + /// + /// It should only be `true` when we have a root animation like `jump` or root `swap`. + @Published var onRootAnimated: Bool = false + + /// All elements in the view stack. + /// + /// Being used for obtaining the data and displaying view stack elements in `DefinedViewStack` module. + /// + /// - Important: You should NOT modify this immediately! + /// This array will be synced automatically with one inside `DefinedViewStackContainer`. + /// So manually modifying this array may result in fatal bugs. + @Published var elements: [DefinedViewStackElement] = [] + + /// An offset storer for all views. + /// + /// Being used for dynamically control the offset of each view inside view stack. + @Published var offsets: [CGFloat] = [0] + + /// The entire width of stack view area. + /// + /// - Note: Not the width of screen! + @Published var width: CGFloat = 0 + + /// The entire height of stack view area. + /// + /// - Note: Not the height of screen! + @Published var height: CGFloat = 0 + + /// The safe area insets of current view stack. + @Published var safeAreaInsets: EdgeInsets = EdgeInsets.init() + + /// [Deprecated] The overall opacity of current view stack. + /// + /// - TODO: We are not using the overall opacity for now. + @Published var overallOpacity: Double = 1.0 + + /// [DE Internal] Create the view stack manager with corresponding name and status bar flag. + /// + /// - Parameters: + /// - name: The name of the view stack. + /// - statusBar: Should this view stack modify the status bar on changing the page? + internal init(name: String, statusBar: Bool) { + self.name = name + self.shouldUseStatusBar = statusBar + } + + /// The view stack container. + /// + /// It will automatically sync with `elements` property when view stack has been changed. + internal var viewStack = DefinedViewStackContainer() { + didSet { + // get update from the container. + elements = viewStack.getStack() + } + } + + /// [DE Internal] Manual push maneuver. + /// + /// Push a page element onto the view stack. + /// + /// - Parameter target: The sub-page going to be pushed. + internal func push(_ target: Page) where Page: DefinedPage { + if self.shouldUseStatusBar { + UIApplication.setStatusBarStyle(target.statusBarStyle) + } + + // push a 0 first so the view can be updated safely. + self.offsets.append(0) + + withAnimation(.easeInOut(duration: 0.30)) { + self.viewStack.push(DefinedViewStackElement(target)) + + if self.viewStack.stack.count > 1 { + self.offsets[self.offsets.count - 2] = -self.width / 4 + } + } + } + + /// [DE Internal] Manual pop maneuver. + /// + /// Pop a page element onto the view stack. + internal func pop() { + if self.viewStack.stack.count > 1 { + if self.shouldUseStatusBar { + UIApplication.setStatusBarStyle(self.elements[self.elements.count - 2].statusBarStyle) + } + + withAnimation(.easeInOut(duration: 0.25)) { + if (self.offsets[self.offsets.count - 2] == 0) { + // immediately pop when we clicked the back button (no drag gesture). + self.viewStack.pop() + } else { + // this is an animation for the case that we drag the view. + + // BUG: there is still a time gap between popping up the top page and correctly positioning the second top page into the right place. + + withAnimation(.easeInOut(duration: 0.27)) { + self.viewStack.pop() + } + } + // reset the offset and get ready for next navigation action. + self.offsets[self.offsets.count - 2] = 0 + } + self.offsets.removeLast() + } else { + // ERROR: should NOT be able to pop right now!!! We only have 1 page left! + } + } + + /// [DE Internal] Manual replace maneuver. + /// + /// Replace all pages in the stack with a new page as the new root. + /// + /// - Parameter target: The page going to replace others. + internal func jump(_ target: Page) where Page: DefinedPage { + if self.shouldUseStatusBar { + UIApplication.setStatusBarStyle(target.statusBarStyle) + } + + withAnimation(.easeInOut(duration: 0.50)) { + self.renew() + self.viewStack.push(DefinedViewStackElement(target)) + } + } + + /// Renew the view stack (remove all page elements and place an original 0 to offset in order to avoid bugging when pushing the first page). + private func renew() { + self.viewStack.removeAll() + self.offsets = [0] + } + + /// [DE Internal] Set the status bar style of top page manually. + /// + /// - Parameter style: The target style. + internal func setStatusBarStyle(_ style: UIStatusBarStyle) { + self.elements[self.elements.count - 1].statusBarStyle = style + UIApplication.setStatusBarStyle(style) + } + + /// [DE Internal] Set the status bar style of given page (UUID) manually. + /// + /// - Parameters: + /// - pageId: The UUID of the page you want to change. + /// - style: The target style. + func setStatusBarStyle(pageId: UUID, style: UIStatusBarStyle) { + let index = self.elements.firstIndex(where: { $0.id == pageId }) + if index != nil { + self.elements[index!].statusBarStyle = style + if (index == self.elements.count - 1) { + UIApplication.setStatusBarStyle(style) + } + } + + } +} + +// MARK: - StackManager Pool + +extension DefinedViewStackManager { + /// The UUID correspond to the root view stack. + internal static let rootId: UUID = UUID() + + /// The global pool of `DefinedViewStackManager`. + private static var pool: [UUID: [String: DefinedViewStackManager]] = [:] + + /// [DE Internal] Get corresponding `DefinedViewStackManager` from the pool. + /// + /// - Parameters: + /// - name: The name of the stack. + /// - pageId: The page id (where the stack is AT, not the top page of the stack). + /// - shouldUseStatusBar: Should this stack modify the status bar style during navigation? + /// - rebuild: Should we recreate a new `DVStackManager` if there already exists one? + /// + /// - Note: If we already have a corresponding `DVStackManager` and you choose not to rebuild one, the `shouldUseStatusBar` will NOT be modified. It will keep the old configuration. + /// + /// - Returns: The corresponding `DVStackManager`. + internal static func get( + name: String, + pageId: UUID, + shouldUseStatusBar: Bool = false, + rebuild: Bool = false + ) -> DefinedViewStackManager { + if pool[pageId] == nil { + pool[pageId] = [:] + } + if pool[pageId]![name] == nil || rebuild { + let newManager = DefinedViewStackManager(name: name, statusBar: shouldUseStatusBar) + pool[pageId]![name] = newManager + } + return pool[pageId]![name]! + } + + /// [DE Internal] Check if we should register this stack (check if it exists). + /// + /// - Parameters: + /// - name: The name of the stack. + /// - pageId: The page id (where the stack is AT, not the top page of the stack). + /// + /// - Returns: True for we should register it. False for we should not re-register it. + internal static func shouldRegister(name: String, pageId: UUID) -> Bool { + return pool[pageId]?[name] == nil + } +} + +#endif