From 0919404b2e71839fc551c5c61f7001b3622ffd92 Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 20 Dec 2019 21:59:34 +0100 Subject: [PATCH] Add support for customizing back button image. (#253) For customizing back button image we use platform native functionality that is: `setBackIndicatorImage` on iOS and `setHomeAsUpIndicator` on Android. The reason we don't do that just by setting left item is that we get a couple of things for free such as handling RTL properly, working accessibility features, handling prop for hiding back button and a couple more. Unfortunately there are some downsides to that approach too. We need to install the back button as an Image component from the JS side, and the extract the image payload on the native side to set it with the navigator. This is specifically problematic in DEV mode where images are loaded asynchronously over HTTP from the packager. In order for that to work we had to employ a few hacks (more comments on that in the code). --- .../rnscreens/ScreenStackHeaderConfig.java | 13 +++ .../rnscreens/ScreenStackHeaderSubview.java | 3 +- .../ScreenStackHeaderSubviewManager.java | 2 + createNativeStackNavigator.js | 7 ++ ios/RNSScreenStackHeaderConfig.h | 1 + ios/RNSScreenStackHeaderConfig.m | 91 ++++++++++++++++++- src/screens.native.js | 10 ++ 7 files changed, 122 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java index 23ff439c..90ef863f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java @@ -8,6 +8,7 @@ import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; +import android.widget.ImageView; import android.widget.TextView; import androidx.appcompat.app.ActionBar; @@ -15,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; +import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.views.text.ReactFontManager; import java.util.ArrayList; @@ -184,6 +186,17 @@ public class ScreenStackHeaderConfig extends ViewGroup { ScreenStackHeaderSubview view = mConfigSubviews.get(i); ScreenStackHeaderSubview.Type type = view.getType(); + if (type == ScreenStackHeaderSubview.Type.BACK) { + // we special case BACK button header config type as we don't add it as a view into toolbar + // but instead just copy the drawable from imageview that's added as a first child to it. + View firstChild = view.getChildAt(0); + if (!(firstChild instanceof ImageView)) { + throw new JSApplicationIllegalArgumentException("Back button header config view should have Image as first child"); + } + actionBar.setHomeAsUpIndicator(((ImageView) firstChild).getDrawable()); + continue; + } + Toolbar.LayoutParams params = new Toolbar.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java index ab1f947a..0a0c9e09 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java @@ -18,7 +18,8 @@ public class ScreenStackHeaderSubview extends ReactViewGroup { LEFT, CENTER, TITLE, - RIGHT + RIGHT, + BACK } private int mReactWidth, mReactHeight; diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java index 96d38bd0..f19897ba 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java @@ -47,6 +47,8 @@ public class ScreenStackHeaderSubviewManager extends ReactViewManager { view.setType(ScreenStackHeaderSubview.Type.TITLE); } else if ("right".equals(type)) { view.setType(ScreenStackHeaderSubview.Type.RIGHT); + } else if ("back".equals(type)) { + view.setType(ScreenStackHeaderSubview.Type.BACK); } } } diff --git a/createNativeStackNavigator.js b/createNativeStackNavigator.js index 856692a5..32e4eac6 100644 --- a/createNativeStackNavigator.js +++ b/createNativeStackNavigator.js @@ -12,6 +12,7 @@ import { ScreenStack, Screen, ScreenStackHeaderConfig, + ScreenStackHeaderBackButtonImage, ScreenStackHeaderLeftView, ScreenStackHeaderRightView, ScreenStackHeaderTitleView, @@ -93,6 +94,12 @@ class StackView extends React.Component { const children = []; + if (options.backButtonImage) { + children.push( + + ); + } + if (options.headerLeft !== undefined) { children.push( diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h index 6c9b90af..b99d963b 100644 --- a/ios/RNSScreenStackHeaderConfig.h +++ b/ios/RNSScreenStackHeaderConfig.h @@ -34,6 +34,7 @@ @end typedef NS_ENUM(NSInteger, RNSScreenStackHeaderSubviewType) { + RNSScreenStackHeaderSubviewTypeBackButton, RNSScreenStackHeaderSubviewTypeLeft, RNSScreenStackHeaderSubviewTypeRight, RNSScreenStackHeaderSubviewTypeTitle, diff --git a/ios/RNSScreenStackHeaderConfig.m b/ios/RNSScreenStackHeaderConfig.m index 3992b611..d34d5a4d 100644 --- a/ios/RNSScreenStackHeaderConfig.m +++ b/ios/RNSScreenStackHeaderConfig.m @@ -5,6 +5,20 @@ #import #import #import +#import +#import +#import + +// Some RN private method hacking below. Couldn't figure out better way to access image data +// of a given RCTImageView. See more comments in the code section processing SubviewTypeBackButton +@interface RCTImageView (Private) +- (UIImage*)image; +@end + +@interface RCTImageLoader (Private) +- (id)imageCache; +@end + @interface RNSScreenHeaderItemMeasurements : NSObject @property (nonatomic, readonly) CGSize headerSize; @@ -30,6 +44,7 @@ @interface RNSScreenStackHeaderSubview : UIView +@property (nonatomic, weak) RCTBridge *bridge; @property (nonatomic, weak) UIView *reactSuperview; @property (nonatomic) RNSScreenStackHeaderSubviewType type; @@ -150,6 +165,15 @@ [navbar setLargeTitleTextAttributes:largeAttrs]; } } + + UIImage *backButtonImage = [self loadBackButtonImageInViewController:vc withConfig:config]; + if (backButtonImage) { + navbar.backIndicatorImage = backButtonImage; + navbar.backIndicatorTransitionMaskImage = backButtonImage; + } else if (navbar.backIndicatorImage) { + navbar.backIndicatorImage = nil; + navbar.backIndicatorTransitionMaskImage = nil; + } } } @@ -164,6 +188,59 @@ } } ++ (UIImage*)loadBackButtonImageInViewController:(UIViewController *)vc + withConfig:(RNSScreenStackHeaderConfig *)config +{ + BOOL hasBackButtonImage = NO; + for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { + if (subview.type == RNSScreenStackHeaderSubviewTypeBackButton && subview.subviews.count > 0) { + hasBackButtonImage = YES; + RCTImageView *imageView = subview.subviews[0]; + UIImage *image = imageView.image; + // IMPORTANT!!! + // image can be nil in DEV MODE ONLY + // + // It is so, because in dev mode images are loaded over HTTP from the packager. In that case + // we first check if image is already loaded in cache and if it is, we take it from cache and + // display immediately. Otherwise we wait for the transition to finish and retry updating + // header config. + // Unfortunately due to some problems in UIKit we cannot update the image while the screen + // transition is ongoing. This results in the settings being reset after the transition is done + // to the state from before the transition. + if (image == nil) { + // in DEV MODE we try to load from cache (we use private API for that as it is not exposed + // publically in headers). + RCTImageSource *source = imageView.imageSources[0]; + image = [subview.bridge.imageLoader.imageCache + imageForUrl:source.request.URL.absoluteString + size:source.size + scale:source.scale + resizeMode:imageView.resizeMode]; + } + if (image == nil) { + // This will be triggered if the image is not in the cache yet. What we do is we wait until + // the end of transition and run header config updates again. We could potentially wait for + // image on load to trigger, but that would require even more private method hacking. + if (vc.transitionCoordinator) { + [vc.transitionCoordinator animateAlongsideTransition:^(id _Nonnull context) { + // nothing, we just want completion + } completion:^(id _Nonnull context) { + // in order for new back button image to be loaded we need to trigger another change + // in back button props that'd make UIKit redraw the button. Otherwise the changes are + // not reflected. Here we change back button visibility which is then immediately restored + vc.navigationItem.hidesBackButton = YES; + [config updateViewControllerIfNeeded]; + }]; + } + return [UIImage new]; + } else { + return image; + } + } + } + return nil; +} + + (void)willShowViewController:(UIViewController *)vc withConfig:(RNSScreenStackHeaderConfig *)config { [self updateViewController:vc withConfig:config]; @@ -201,7 +278,6 @@ } navitem.title = config.title; - navitem.hidesBackButton = config.hideBackButton; if (config.backTitle != nil) { prevItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:config.backTitle @@ -290,11 +366,19 @@ appearance.largeTitleTextAttributes = largeAttrs; } + UIImage *backButtonImage = [self loadBackButtonImageInViewController:vc withConfig:config]; + if (backButtonImage) { + [appearance setBackIndicatorImage:backButtonImage transitionMaskImage:backButtonImage]; + } else if (appearance.backIndicatorImage) { + [appearance setBackIndicatorImage:nil transitionMaskImage:nil]; + } + navitem.standardAppearance = appearance; navitem.compactAppearance = appearance; navitem.scrollEdgeAppearance = appearance; } #endif + navitem.hidesBackButton = config.hideBackButton; navitem.leftBarButtonItem = nil; navitem.rightBarButtonItem = nil; navitem.titleView = nil; @@ -378,6 +462,7 @@ RCT_EXPORT_VIEW_PROPERTY(gestureEnabled, BOOL) @implementation RCTConvert (RNSScreenStackHeader) RCT_ENUM_CONVERTER(RNSScreenStackHeaderSubviewType, (@{ + @"back": @(RNSScreenStackHeaderSubviewTypeBackButton), @"left": @(RNSScreenStackHeaderSubviewTypeLeft), @"right": @(RNSScreenStackHeaderSubviewTypeRight), @"title": @(RNSScreenStackHeaderSubviewTypeTitle), @@ -386,9 +471,7 @@ RCT_ENUM_CONVERTER(RNSScreenStackHeaderSubviewType, (@{ @end -@implementation RNSScreenStackHeaderSubview { - __weak RCTBridge *_bridge; -} +@implementation RNSScreenStackHeaderSubview - (instancetype)initWithBridge:(RCTBridge *)bridge { diff --git a/src/screens.native.js b/src/screens.native.js index 26b52686..63335d57 100644 --- a/src/screens.native.js +++ b/src/screens.native.js @@ -4,6 +4,7 @@ import { requireNativeComponent, View, UIManager, + Image, StyleSheet, } from 'react-native'; import { version } from 'react-native/Libraries/Core/ReactNativeVersion'; @@ -146,6 +147,14 @@ const styles = StyleSheet.create({ }, }); +const ScreenStackHeaderBackButtonImage = props => ( + + + +); + const ScreenStackHeaderRightView = props => (