21

AppStore app has an icon with an image on the right side of the NabBar with Large Title: enter image description here

Would really appreciate if anyone knows how to implement it or ideas on how to do it.

BTW: Setting an image for UIButton inside of UIBarButtonItem won't work. Tried already. The button sticks to the top of the screen: enter image description here

Eric Aya
  • 69,473
  • 35
  • 181
  • 253
Tung Fam
  • 7,899
  • 4
  • 56
  • 63
  • @GuidoLodetti it's a bit complex so i'm writing a tutorial for it. is it urgent ? if yes i can share code without explaination. – Tung Fam Nov 16 '17 at 22:22
  • @TungFam The code would be enough! Just to know if it is a complex and unstable solution or if it might be well supported in the next versions of iOS :) We need it for an app update! Thanks. – Guido Lodetti Nov 17 '17 at 11:17
  • It's 100 lines of code:) stable enough. ping me here: tung.fam@uptech.team – Tung Fam Nov 17 '17 at 12:03
  • @TungFam any update on this for iOS 13? Does anybody have news? – Bonnke Nov 02 '19 at 07:15
  • @Bonnke is it broken for iOS 13? I just checked it worked fine for me. Can you please explain what do you mean by your question? – Tung Fam Nov 02 '19 at 13:33
  • @TungFam No, it is not broken. Your solution works brilliant, but wanted to know (I already checked but I did not found) if Apple released a clear solution related to AppStore app, where that image goes under the navigation bar when you scroll up. For eg, in my app, I have a right bar button item, and image goes over it when scroll up. Or I tried to block the navigation to large title height, but no success until now. – Bonnke Nov 02 '19 at 19:29
  • 1
    @Bonnke I assume you created an issue on Github, I answered there: https://github.com/tungfam/ImageInNavigationBarDemo/issues/8 – Tung Fam Nov 03 '19 at 10:02
  • I appreciate @TungFam. – Bonnke Nov 03 '19 at 12:14

5 Answers5

63

After several hours of coding, I finally managed to make it work. I also decided to write a detailed tutorial: link. Follow it in case you prefer very detailed instructions.

Demo: enter image description here

Complete project on GitHub: link.

Here are 5 steps to accomplish it:

Step 1: Create an image

private let imageView = UIImageView(image: UIImage(named: "image_name"))

Step 2: Add Constants

/// WARNING: Change these constants according to your project's design
private struct Const {
    /// Image height/width for Large NavBar state
    static let ImageSizeForLargeState: CGFloat = 40
    /// Margin from right anchor of safe area to right anchor of Image
    static let ImageRightMargin: CGFloat = 16
    /// Margin from bottom anchor of NavBar to bottom anchor of Image for Large NavBar state
    static let ImageBottomMarginForLargeState: CGFloat = 12
    /// Margin from bottom anchor of NavBar to bottom anchor of Image for Small NavBar state
    static let ImageBottomMarginForSmallState: CGFloat = 6
    /// Image height/width for Small NavBar state
    static let ImageSizeForSmallState: CGFloat = 32
    /// Height of NavBar for Small state. Usually it's just 44
    static let NavBarHeightSmallState: CGFloat = 44
    /// Height of NavBar for Large state. Usually it's just 96.5 but if you have a custom font for the title, please make sure to edit this value since it changes the height for Large state of NavBar
    static let NavBarHeightLargeState: CGFloat = 96.5
}

Step 3: setup UI:

private func setupUI() {
    navigationController?.navigationBar.prefersLargeTitles = true

    title = "Large Title"

    // Initial setup for image for Large NavBar state since the the screen always has Large NavBar once it gets opened
    guard let navigationBar = self.navigationController?.navigationBar else { return }
    navigationBar.addSubview(imageView)
    imageView.layer.cornerRadius = Const.ImageSizeForLargeState / 2
    imageView.clipsToBounds = true
    imageView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        imageView.rightAnchor.constraint(equalTo: navigationBar.rightAnchor,
                                         constant: -Const.ImageRightMargin),
        imageView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor,
                                          constant: -Const.ImageBottomMarginForLargeState),
        imageView.heightAnchor.constraint(equalToConstant: Const.ImageSizeForLargeState),
        imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor)
        ])
}

Step 4: create image resizing method

private func moveAndResizeImage(for height: CGFloat) {
    let coeff: CGFloat = {
        let delta = height - Const.NavBarHeightSmallState
        let heightDifferenceBetweenStates = (Const.NavBarHeightLargeState - Const.NavBarHeightSmallState)
        return delta / heightDifferenceBetweenStates
    }()

    let factor = Const.ImageSizeForSmallState / Const.ImageSizeForLargeState

    let scale: CGFloat = {
        let sizeAddendumFactor = coeff * (1.0 - factor)
        return min(1.0, sizeAddendumFactor + factor)
    }()

    // Value of difference between icons for large and small states
    let sizeDiff = Const.ImageSizeForLargeState * (1.0 - factor) // 8.0

    let yTranslation: CGFloat = {
        /// This value = 14. It equals to difference of 12 and 6 (bottom margin for large and small states). Also it adds 8.0 (size difference when the image gets smaller size)
        let maxYTranslation = Const.ImageBottomMarginForLargeState - Const.ImageBottomMarginForSmallState + sizeDiff
        return max(0, min(maxYTranslation, (maxYTranslation - coeff * (Const.ImageBottomMarginForSmallState + sizeDiff))))
    }()

    let xTranslation = max(0, sizeDiff - coeff * sizeDiff)

    imageView.transform = CGAffineTransform.identity
        .scaledBy(x: scale, y: scale)
        .translatedBy(x: xTranslation, y: yTranslation)
}

Step 5:

override func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard let height = navigationController?.navigationBar.frame.height else { return }
    moveAndResizeImage(for: height)
}

Hope it's clear and helps you! Please let me know in comments if you have any additional questions.

Tung Fam
  • 7,899
  • 4
  • 56
  • 63
  • 1
    i saw you so smart on calculus. can you give me a suggestion to learn? – seyha Jul 26 '18 at 08:14
  • @seyha excuse me, what do you mean? – Tung Fam Jul 31 '18 at 20:13
  • i mean you are good at math. can you give me a good suggestion to learn? – seyha Aug 01 '18 at 04:38
  • @seyha oh I see. Thank you:) but to be honest I learned it only in school and university. So maybe in your case I would suggest some online maths courses. – Tung Fam Aug 01 '18 at 08:18
  • I want to make some thing like this But the image is so large and in the center of the screen when user scroll table view the image will be small and will be some thing like this @TungFam – Saeed Rahmatolahi Feb 12 '19 at 16:08
  • this code is incredible, well done! one thing to be aware of is that if you have a nav stack then the image in the top right will stay present on the child views. which is expected when you think about it, but not what I anticipated for some reason. my solution to this was to change `setupUI(show: Bool)` and then ```override func viewWillDisappear(_ animated: Bool) { setupScrollingProfileImage(show: false) super.viewWillDisappear(animated) }``` – lewis Aug 09 '22 at 15:49
  • nice math but its not working properly when search controller is applied..can you give me additional mathematics for the nav bar having search controller? – Sptibo Feb 19 '23 at 07:51
2

If anyone is still looking how to do this in SwiftUI. I made a package named NavigationBarLargeTitleItems to deal with this. It mimics the behavior you see in the AppStore and Messages-app.

Please note to be able to accomplish this behavior we need to add to the '_UINavigationBarLargeTitleView' which is a private class and therefor might get your app rejected when submitting to the App Store.

I'm also including the full relevant source code here for those who dislike links or just want to copy/paste.

Extension:

// Copyright © 2020 Mark van Wijnen
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI

public extension View {
    func navigationBarLargeTitleItems<L>(trailing: L) -> some View where L : View {
        overlay(NavigationBarLargeTitleItems(trailing: trailing).frame(width: 0, height: 0))
    }
}

fileprivate struct NavigationBarLargeTitleItems<L : View>: UIViewControllerRepresentable {
    typealias UIViewControllerType = Wrapper
    
    private let trailingItems: L
    
    init(trailing: L) {
        self.trailingItems = trailing
    }
    
    func makeUIViewController(context: Context) -> Wrapper {
        Wrapper(representable: self)
    }
    
    func updateUIViewController(_ uiViewController: Wrapper, context: Context) {
    }
    
    class Wrapper: UIViewController {
        private let representable: NavigationBarLargeTitleItems?
        
        init(representable: NavigationBarLargeTitleItems) {
            self.representable = representable
            super.init(nibName: nil, bundle: nil)
        }
        
        required init?(coder: NSCoder) {
            self.representable = nil
            super.init(coder: coder)
        }
                
        override func viewWillAppear(_ animated: Bool) {
            guard let representable = self.representable else { return }
            guard let navigationBar = self.navigationController?.navigationBar else { return }
            guard let UINavigationBarLargeTitleView = NSClassFromString("_UINavigationBarLargeTitleView") else { return }
           
            navigationBar.subviews.forEach { subview in
                if subview.isKind(of: UINavigationBarLargeTitleView.self) {
                    let controller = UIHostingController(rootView: representable.trailingItems)
                    controller.view.translatesAutoresizingMaskIntoConstraints = false
                    subview.addSubview(controller.view)
                    
                    NSLayoutConstraint.activate([
                        controller.view.bottomAnchor.constraint(
                            equalTo: subview.bottomAnchor,
                            constant: -15
                        ),
                        controller.view.trailingAnchor.constraint(
                            equalTo: subview.trailingAnchor,
                            constant: -view.directionalLayoutMargins.trailing
                        )
                    ])
                }
            }
        }
    }
}

Usage:

import SwiftUI
import NavigationBarLargeTitleItems

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach(1..<50) { index in
                    Text("Sample Row \(String(index))")
                }
            }
            .navigationTitle("Navigation")
            .navigationBarLargeTitleItems(trailing: ProfileIcon())
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct ProfileIcon: View {
    var body: some View{
        Button(action: {
            print("Profile button was tapped")
        }) {
            Image(systemName: "person.circle.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .foregroundColor(.red)
                .frame(width: 36, height: 36)
        }
        .offset(x: -20, y: 5)
    }
}

Preview

enter image description here

Mark
  • 16,906
  • 20
  • 84
  • 117
0

Thanks to @TungFam, I think I have a better solution. check it out

demo

two points:

  1. change button frame according to navigation bar height

      // adjust topview height
    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard  let navBar = self.navigationController?.navigationBar else {
            return
        }
        // hardcoded .. to improve
        if navBar.bounds.height > 44 + 40 + 10 {
            NSLayoutConstraint.deactivate(heightConstraint)
            heightConstraint = [topview.heightAnchor.constraint(equalToConstant: 40)]
            NSLayoutConstraint.activate(heightConstraint)
        } else {
            NSLayoutConstraint.deactivate(heightConstraint)
            var  height = navBar.bounds.height - 44 - 10
            if height < 0 {
                height = 0
            }
            heightConstraint = [topview.heightAnchor.constraint(equalToConstant: height)]
            NSLayoutConstraint.activate(heightConstraint)
    
        }
    
    }
    
  2. change button alpha according to pop/push progress

    @objc func onGesture(sender: UIGestureRecognizer) {
        switch sender.state {
        case .began, .changed:
            if let ct = navigationController?.transitionCoordinator {
                topview.alpha =  ct.percentComplete
            }
        case .cancelled, .ended:
            return
        case .possible, .failed:
            break
        }
    }
    
huahuahu
  • 101
  • 2
  • 4
0

Nice answer about adding it as a subview. I would add the fact that you could use pure auto layout only without the need of CGAffineTransform and all those calculations. If you add vertical constraints as well it will automatically scale. If you still need to use calculations you can use navigationController?.navigationBar.publisher(for: \.frame) publisher instead of doing it inside scroll view. That way you'll be able to do it more globally rather than being dependent on the scroll view.

This is how I did it for example (I needed to do it on leading and have large title hidden but you can change those constraints to add it wherever you'd like):

  1. Add imageView as a property as I also need to hide it in some cases. (e.g., when opening other screen)
private lazy var imageView: UIImageView = {
    let imageView = UIImageView()
    imageView.kf.setImage(with: URL(string: "https://img.buzzfeed.com/buzzfeed-static/static/2021-07/21/15/campaign_images/b4661163b3f8/24-times-michael-scott-from-the-office-made-us-bu-2-7356-1626879661-2_dblbig.jpg?resize=1200:*")!)
    imageView.cornerRadiusStyle = .heightFraction(1/2) // This is an extension in the codebase I'm working on but you can set the corner radius normally as you would. Inside layoutSubviews most probably.
    imageView.clipsToBounds = true
    imageView.translatesAutoresizingMaskIntoConstraints = false
    imageView.contentMode = .scaleAspectFill
    imageView.isUserInteractionEnabled = true
    return imageView
}()
  1. Setup custom image (Make sure you call this AFTER navigationController is set and not nil)
func setupCustomImage() {
    // Adding imageView inside stackView just for convenience of hiding it later.
    let stackView = UIStackView()
    stackView.axis = .horizontal
    stackView.translatesAutoresizingMaskIntoConstraints = false
    stackView.addArrangedSubview(imageView)
        
    NSLayoutConstraint.activate([
        imageView.heightAnchor.constraint(lessThanOrEqualToConstant: 52), // In my case I needed max image size to be 52. You can change that.
        imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor) // I needed aspect ratio to be 1:1. You can change that also by adding multiplier.
    ])
        
    guard let navigationBar = navigationController?.navigationBar else { return }
        
    navigationBar.addSubview(stackView)
        
    NSLayoutConstraint.activate([
        stackView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor, constant: 16), // For leading padding
        stackView.centerYAnchor.constraint(equalTo: navigationBar.centerYAnchor),
    // You can play around with those constants as well to provide minimum size of the image needed.
        navigationBar.bottomAnchor.constraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: 7),
        stackView.topAnchor.constraint(greaterThanOrEqualTo: navigationBar.topAnchor, constant: 7)
    ])
}

It will automatically do all the scaling and stuff.

Eric Aya
  • 69,473
  • 35
  • 181
  • 253
-1

You could create the UIBarButtonItem using a custom view. This custom view will be a UIView with the actual UIButton (as a subview) placed x pixels from the top (x=the number of pixels you want to move it down).

Casey West
  • 578
  • 5
  • 22