├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Screenshots.png └── Sources └── MatchedInlineTitle ├── Internal ├── TextContent.swift ├── _MatchedTitleContainer.swift └── _TitlePreferenceKey.swift ├── MatchedTitle.swift └── ScrollView+MatchedInlineTitle.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Seb Jachec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swiftui-matched-inline-title", 7 | platforms: [ 8 | .iOS(.v14) 9 | ], 10 | products: [ 11 | .library( 12 | name: "MatchedInlineTitle", 13 | targets: ["MatchedInlineTitle"]), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "MatchedInlineTitle", 18 | dependencies: []) 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Matched Inline Title 2 | 3 | Transition from any SwiftUI `Text` view into an inline navigation bar title when the view is scrolled off-screen, as seen in Apple's TV & TestFlight iOS apps. 4 | 5 | ## Example 6 | 7 | ```swift 8 | struct ContentView: View { 9 | 10 | @Namespace var namespace 11 | 12 | var body: some View { 13 | NavigationView { 14 | ScrollView { 15 | VStack(alignment: .leading) { 16 | MatchedTitle("For All Mankind", namespace: namespace) { 17 | $0 18 | .font(.largeTitle) 19 | .fontWeight(.bold) 20 | .padding() 21 | } 22 | 23 | // Other content… 24 | } 25 | } 26 | .matchedInlineTitle(in: namespace) 27 | // Other view modifiers, including `Toolbar` can be used… 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | ![Screenshots](Screenshots.png) 34 | 35 | Naming and namespacing conventions follow Swift's existing `.matchedGeometryEffect` view modifier. 36 | 37 | A `MatchedTitle` view is initialized with any `String`, `SubString` or `LocalizedStringKey`, using similar parameters to SwiftUI's `Text`, and it provides optional customisation of the created `Text` view. 38 | 39 | The `.matchedInlineTitle` modifier must be used on a `ScrollView`, and enforces a navigation bar title display mode of `inline`. 40 | 41 | ## Minimum Requirements 42 | 43 | * iOS 14.0 44 | * Swift 5.3 45 | 46 | ## License 47 | 48 | This library is released under the MIT license. See the [LICENSE](LICENSE) file for more information. 49 | -------------------------------------------------------------------------------- /Screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebj/swiftui-matched-inline-title/a86cc6266c20ea476046f8402065ef9875e31c2f/Screenshots.png -------------------------------------------------------------------------------- /Sources/MatchedInlineTitle/Internal/TextContent.swift: -------------------------------------------------------------------------------- 1 | 2 | // TextContent.swift 3 | // Copyright © 2021 Sebastian Jachec. All rights reserved. 4 | 5 | import SwiftUI 6 | 7 | enum TextContent { 8 | case string(String) 9 | case localizedString(key: LocalizedStringKey, tableName: String?, bundle: Bundle?, comment: StaticString?) 10 | } 11 | 12 | extension TextContent: Equatable { 13 | static func == (lhs: TextContent, rhs: TextContent) -> Bool { 14 | switch (lhs, rhs) { 15 | case let (.string(lString), .string(rString)): 16 | return lString == rString 17 | case let 18 | (.localizedString(lKey, lTableName, lBundle, lComment), .localizedString(rKey, rTableName, rBundle, rComment)): 19 | return lKey == rKey 20 | && lTableName == rTableName 21 | && lBundle == rBundle 22 | && "\(String(describing: lComment))" == "\(String(describing: rComment))" 23 | default: 24 | return false 25 | } 26 | } 27 | } 28 | 29 | extension TextContent { 30 | var view: Text { 31 | switch self { 32 | case let .string(value): 33 | return Text(value) 34 | case let .localizedString(key, tableName, bundle, comment): 35 | return Text(key, tableName: tableName, bundle: bundle, comment: comment) 36 | // TODO: AttributedString for iOS 15 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/MatchedInlineTitle/Internal/_MatchedTitleContainer.swift: -------------------------------------------------------------------------------- 1 | 2 | // _MatchedTitleContainer.swift 3 | // Copyright © 2021 Sebastian Jachec. All rights reserved. 4 | 5 | import SwiftUI 6 | 7 | struct _MatchedTitleContainer: View where Content: View { 8 | 9 | @State private var title: TextContent? 10 | @State private var isVisible = false 11 | private let content: () -> Content 12 | 13 | init(@ViewBuilder _ content: @escaping () -> Content) { 14 | self.content = content 15 | } 16 | 17 | var body: some View { 18 | content() 19 | .onPreferenceChange(_TitlePreferenceKey.self) { data in 20 | title = data?.title 21 | isVisible = data?.isVisible ?? false 22 | } 23 | .toolbar { 24 | ToolbarItem(placement: .principal) { 25 | title.map(\.view) 26 | .lineLimit(1) 27 | .accessibilityAddTraits(.isHeader) 28 | .opacity(isVisible ? 1 : 0) 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/MatchedInlineTitle/Internal/_TitlePreferenceKey.swift: -------------------------------------------------------------------------------- 1 | 2 | // _TitlePreferenceKey.swift 3 | // Copyright © 2021 Sebastian Jachec. All rights reserved. 4 | 5 | import SwiftUI 6 | 7 | struct _TitlePreferenceData: Equatable { 8 | let title: TextContent 9 | let isVisible: Bool 10 | } 11 | 12 | struct _TitlePreferenceKey: PreferenceKey { 13 | 14 | typealias Value = _TitlePreferenceData? 15 | 16 | static var defaultValue: _TitlePreferenceData? 17 | 18 | static func reduce(value: inout Value, nextValue: () -> Value) { 19 | value = nextValue() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/MatchedInlineTitle/MatchedTitle.swift: -------------------------------------------------------------------------------- 1 | 2 | // MatchedTitle.swift 3 | // Copyright © 2021 Sebastian Jachec. All rights reserved. 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 14, *) 8 | @available(watchOS, unavailable) 9 | public struct MatchedTitle: View where Content: View { 10 | 11 | private let textContent: TextContent 12 | private let namespace: Namespace.ID 13 | private let content: (Text) -> Content 14 | 15 | /// Creates a title view that displays a stored string without localization. 16 | /// 17 | /// - Parameters: 18 | /// - string: The string value to display without localization. 19 | /// - namespace: The namespace in which the title is defined. New namespaces are created by adding an `@Namespace()` variable 20 | /// to a ``View`` type and reading its value in the view's body method. 21 | /// - content: A closure to customise the `Text`. 22 | public init( 23 | _ string: S, 24 | namespace: Namespace.ID, 25 | @ViewBuilder _ content: @escaping (Text) -> Content) where S: StringProtocol 26 | { 27 | self.textContent = .string(String(string)) 28 | self.namespace = namespace 29 | self.content = content 30 | } 31 | 32 | /// Creates a title view that displays a string literal without localization. 33 | /// 34 | /// - Parameters: 35 | /// - string: A string to display without localization. 36 | /// - namespace: The namespace in which the title is defined. New namespaces are created by adding an `@Namespace()` variable 37 | /// to a ``View`` type and reading its value in the view's body method. 38 | /// - content: A closure to customise the `Text`. 39 | public init( 40 | verbatim string: String, 41 | namespace: Namespace.ID, 42 | @ViewBuilder _ content: @escaping (Text) -> Content) 43 | { 44 | self.textContent = .string(string) 45 | self.namespace = namespace 46 | self.content = content 47 | } 48 | 49 | /// Creates a title view that displays localized content identified by a key. 50 | /// - Parameters: 51 | /// - key: The key for a string in the table identified by `tableName`. 52 | /// - tableName: The name of the string table to search. If `nil`, use the 53 | /// table in the `Localizable.strings` file. 54 | /// - bundle: The bundle containing the strings file. If `nil`, use the 55 | /// main bundle. 56 | /// - comment: Contextual information about this key-value pair. 57 | /// - namespace: The namespace in which the title is defined. New namespaces are created by adding an `@Namespace()` variable 58 | /// to a ``View`` type and reading its value in the view's body method. 59 | /// - content: A closure to customise the `Text`. 60 | public init( 61 | _ key: LocalizedStringKey, 62 | tableName: String? = nil, 63 | bundle: Bundle? = nil, 64 | comment: StaticString? = nil, 65 | namespace: Namespace.ID, 66 | @ViewBuilder _ content: @escaping (Text) -> Content) 67 | { 68 | self.textContent = .localizedString(key: key, tableName: tableName, bundle: bundle, comment: comment) 69 | self.namespace = namespace 70 | self.content = content 71 | } 72 | 73 | public var body: some View { 74 | content(textContent.view) 75 | .overlay( 76 | GeometryReader { geometry in 77 | let frame = geometry.frame(in: .named(namespace)) 78 | 79 | Color.clear 80 | .hidden() 81 | .preference( 82 | key: _TitlePreferenceKey.self, 83 | value: _TitlePreferenceData( 84 | title: self.textContent, 85 | isVisible: -frame.origin.y > frame.height)) 86 | } 87 | ) 88 | } 89 | } 90 | 91 | public extension MatchedTitle where Content == Text { 92 | 93 | init(_ string: S, namespace: Namespace.ID) where S: StringProtocol { 94 | self.init(string, namespace: namespace) { $0 } 95 | } 96 | 97 | init(verbatim string: String, namespace: Namespace.ID) { 98 | self.init(verbatim: string, namespace: namespace) { $0 } 99 | } 100 | 101 | init( 102 | _ key: LocalizedStringKey, 103 | tableName: String? = nil, 104 | bundle: Bundle? = nil, 105 | comment: StaticString? = nil, 106 | namespace: Namespace.ID) 107 | { 108 | self.init( 109 | key, 110 | tableName: tableName, 111 | bundle: bundle, 112 | comment: comment, 113 | namespace: namespace) 114 | { 115 | $0 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/MatchedInlineTitle/ScrollView+MatchedInlineTitle.swift: -------------------------------------------------------------------------------- 1 | 2 | // ScrollView+MatchedInlineTitle.swift 3 | // Copyright © 2021 Sebastian Jachec. All rights reserved. 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 14, *) 8 | @available(watchOS, unavailable) 9 | public extension ScrollView { 10 | 11 | /// Configures the inline navigation title for this view to match an existing `MatchedTitle` with the same `namespace`. 12 | /// - Parameter namespace: The namespace in which the title is defined. New 13 | /// namespaces are created by adding an `@Namespace()` variable 14 | /// to a ``View`` type and reading its value in the view's body 15 | /// method. 16 | func matchedInlineTitle(in namespace: Namespace.ID) -> some View { 17 | _MatchedTitleContainer { 18 | self 19 | .coordinateSpace(name: namespace) 20 | .navigationBarTitleDisplayMode(.inline) 21 | } 22 | } 23 | } 24 | --------------------------------------------------------------------------------