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).
This commit is contained in:
Krzysztof Magiera
2019-12-20 21:59:34 +01:00
committed by GitHub
parent e61b8b3bd6
commit 0919404b2e
7 changed files with 122 additions and 5 deletions

View File

@@ -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);

View File

@@ -18,7 +18,8 @@ public class ScreenStackHeaderSubview extends ReactViewGroup {
LEFT,
CENTER,
TITLE,
RIGHT
RIGHT,
BACK
}
private int mReactWidth, mReactHeight;

View File

@@ -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);
}
}
}

View File

@@ -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(
<ScreenStackHeaderBackButtonImage source={options.backButtonImage} />
);
}
if (options.headerLeft !== undefined) {
children.push(
<ScreenStackHeaderLeftView key="left">

View File

@@ -34,6 +34,7 @@
@end
typedef NS_ENUM(NSInteger, RNSScreenStackHeaderSubviewType) {
RNSScreenStackHeaderSubviewTypeBackButton,
RNSScreenStackHeaderSubviewTypeLeft,
RNSScreenStackHeaderSubviewTypeRight,
RNSScreenStackHeaderSubviewTypeTitle,

View File

@@ -5,6 +5,20 @@
#import <React/RCTUIManager.h>
#import <React/RCTUIManagerUtils.h>
#import <React/RCTShadowView.h>
#import <React/RCTImageLoader.h>
#import <React/RCTImageView.h>
#import <React/RCTImageSource.h>
// 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<RCTImageCache>)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<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
// nothing, we just want completion
} completion:^(id<UIViewControllerTransitionCoordinatorContext> _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
{

View File

@@ -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 => (
<ScreensNativeModules.NativeScreenStackHeaderSubview
type="back"
style={styles.headerSubview}>
<Image resizeMode="center" fadeDuration={0} {...props} />
</ScreensNativeModules.NativeScreenStackHeaderSubview>
);
const ScreenStackHeaderRightView = props => (
<ScreensNativeModules.NativeScreenStackHeaderSubview
{...props}
@@ -198,6 +207,7 @@ module.exports = {
get ScreenStackHeaderSubview() {
return ScreensNativeModules.NativeScreenStackHeaderSubview;
},
ScreenStackHeaderBackButtonImage,
ScreenStackHeaderRightView,
ScreenStackHeaderLeftView,
ScreenStackHeaderTitleView,